Sfoglia il codice sorgente

Merge branch 'master' into imprv/refactor-acl

Yuki Takei 7 anni fa
parent
commit
79478a62c1
36 ha cambiato i file con 787 aggiunte e 420 eliminazioni
  1. 9 1
      CHANGES.md
  2. 17 14
      README.md
  3. 1 0
      config/env.dev.js
  4. 4 4
      package.json
  5. 12 12
      resource/locales/en-US/sandbox.md
  6. 6 2
      resource/locales/en-US/translation.json
  7. 5 5
      resource/locales/en-US/welcome.md
  8. 7 3
      resource/locales/ja/translation.json
  9. 4 1
      src/client/js/app.js
  10. 73 56
      src/client/js/components/PageComment/CommentForm.jsx
  11. 6 6
      src/client/js/components/PageEditor/Editor.jsx
  12. 206 75
      src/client/js/components/PageEditor/HandsontableModal.jsx
  13. 0 54
      src/client/js/components/PageEditor/HandsontableUtil.js
  14. 6 1
      src/client/js/components/PageHistory.js
  15. 0 57
      src/client/js/components/PageHistory/PageRevisionList.js
  16. 111 0
      src/client/js/components/PageHistory/PageRevisionList.jsx
  17. 0 65
      src/client/js/components/PageHistory/Revision.js
  18. 113 0
      src/client/js/components/PageHistory/Revision.jsx
  19. 1 0
      src/client/styles/agile-admin/inverse/colors/_apply-colors.scss
  20. 26 0
      src/client/styles/scss/_handsontable.scss
  21. 4 0
      src/client/styles/scss/_on-edit.scss
  22. 19 2
      src/client/styles/scss/_page.scss
  23. 8 0
      src/server/crowi/express-init.js
  24. 3 3
      src/server/form/invited.js
  25. 3 3
      src/server/form/login.js
  26. 2 2
      src/server/form/me/password.js
  27. 3 3
      src/server/form/register.js
  28. 10 0
      src/server/models/config.js
  29. 6 2
      src/server/models/revision.js
  30. 51 13
      src/server/models/user.js
  31. 22 8
      src/server/routes/admin.js
  32. 4 0
      src/server/routes/login-passport.js
  33. 27 17
      src/server/routes/login.js
  34. 1 1
      src/server/views/admin/security.html
  35. 8 1
      src/server/views/admin/users.html
  36. 9 9
      yarn.lock

+ 9 - 1
CHANGES.md

@@ -1,12 +1,20 @@
 CHANGES
 ========
 
-## 3.2.6-RC
+## 3.2.7-RC
 
 * Feature: Import CSV/TSV/HTML table on Spreadsheet like GUI (Handsontable)
+
+## 3.2.6
+
 * Feature: Add select alignment buttons of Spreadsheet like GUI (Handsontable)
+* Fix: Login form rejects weak password
 * Fix: An error occured by uploading attachment file when the page is not exists
     * Introduced by 2.3.5
+* Support: Upgrade libs
+    * i18next-express-middleware
+    * i18next-node-fs-backend
+    * i18next-sprintf-postprocessor
 
 ## 3.2.5
 

+ 17 - 14
README.md

@@ -195,18 +195,6 @@ Documentation
 Contribution
 ============
 
-For development
--------------
-
-### Build and Run the app
-
-1. `clone` this repository
-2. `yarn` to install all dependencies
-    * DO NOT USE `npm install`
-3. `npm run build` to build client app
-4. `npm run server` to start the dev server
-5. Access `http://0.0.0.0:3000`
-
 Found a Bug?
 -------------
 
@@ -224,8 +212,23 @@ Repository. If you would like to *implement* a new feature, firstly please submi
 It also allows us to coordinate better, prevent duplication of work and help you to create the change so it can be successfully accepted into the project.
 * **Small Features** can be created and directly [submitted as a Pull Request][pulls].
 
-Language
----------
+Translation
+--------------
+
+### for GROWI system
+
+We have [the Transifex Project for GROWI](https://www.transifex.com/weseek-inc/growi).  
+Please join to our team!
+
+### for documents
+
+*We have [Gitbook site](https://docs.growi.org), but currently Gitbook doesn't support Multi-langage.*  
+-> https://docs.gitbook.com/v2-changes/important-differences#multi-language-books
+
+*We have to wait until it is implemented.*
+
+Language on GitHub
+------------------
 
 You can write issues and PRs in English or Japanese.
 

+ 1 - 0
config/env.dev.js

@@ -8,6 +8,7 @@ module.exports = {
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',
   ],
+  // USER_UPPER_LIMIT: 0,
   // DEV_HTTPS: true,
   // PUBLIC_WIKI_ONLY: true,
 };

+ 4 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.2.6-RC",
+  "version": "3.2.7-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -89,9 +89,9 @@
     "graceful-fs": "^4.1.11",
     "growi-pluginkit": "^1.1.0",
     "helmet": "^3.13.0",
-    "i18next": "^11.1.1",
-    "i18next-express-middleware": "^1.1.1",
-    "i18next-node-fs-backend": "^2.0.0",
+    "i18next": "^12.0.0",
+    "i18next-express-middleware": "^1.4.1",
+    "i18next-node-fs-backend": "^2.1.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "markdown-it-blockdiag": "^1.0.2",
     "md5": "^2.2.1",

+ 12 - 12
resource/locales/en-US/sandbox.md

@@ -19,18 +19,18 @@
 先頭に`#`をレベルの数だけ記述します。
 
 ```
-# 見出し1
-## 見出し2
-### 見出し3
-#### 見出し4
-##### 見出し5
-###### 見出し6
-```
-
-### 見出し3
-#### 見出し4
-##### 見出し5
-###### 見出し6
+# Header 1
+## Header 2
+### Header 3
+#### Header 4
+##### Header 5
+###### Header 6
+```
+
+### Header 3
+#### Header 4
+##### Header 5
+###### Header 6
 
 ## Block 段落
 

+ 6 - 2
resource/locales/en-US/translation.json

@@ -46,8 +46,10 @@
 
   "Unportalize": "Unportalize",
 
-  "View this version": "View this version",
+  "Go to this version": "View this version",
   "View diff": "View diff",
+  "No diff": "No diff",
+  "Shrink versions that have no diffs": "Shrink versions that have no diffs",
 
   "User ID": "User ID",
   "Home": "Home",
@@ -517,7 +519,9 @@
     "Deactivate account":"Deactivate account",
     "your_own":"You cannot deactivate your own account",
     "Administrator menu":"Administrator menu",
-    "cannot_remove":"You cannot remove yourself from administrator"
+    "cannot_remove":"You cannot remove yourself from administrator",
+    "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.",
+    "current users": "Current users:"
   },
 
   "importer_management": {

+ 5 - 5
resource/locales/en-US/welcome.md

@@ -6,8 +6,8 @@
 <div class="panel panel-default">
   <div class="panel-heading">Tips</div>
   <div class="panel-body"><ul>
-    <li>Ctrl(⌘)-/ でショートカットヘルプを表示します</li>
-      <li>HTML/CSS の記述時は、<a href="https://getbootstrap.com/docs/3.3/css/">Bootstrap 3</a> を利用できます</li>
+    <li>Ctrl(⌘)-/ to show quick help</li>
+    <li>You can <a href="https://getbootstrap.com/docs/3.3/css/">Bootstrap 3</a> to write HTML tags.</li>
   </ul></div>
 </div>
 
@@ -18,12 +18,12 @@ Contents
 
 |All Pages|[/Sandbox]|
 | --- | --- |
-| $lsx(/) | <div class="alert alert-success"><span style="font-size: x-large;"><i class="icon-check"></i> [Sandboxをチェック](/Sandbox)</span></div> $lsx(/Sandbox)|
+| $lsx(/) | <div class="alert alert-success"><span style="font-size: x-large;"><i class="icon-check"></i> [Go to Sandbox](/Sandbox)</span></div> $lsx(/Sandbox)|
 
 Slack
 =====
 
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 
-GROWI をより良いものにするために、是非 Slack に参加してください。  
-開発に関する議論を行っている他、導入時の質問等も受け付けています。
+Please join Slack by all means to make GROWI better.
+In addition to discussing development, we also accept questions at the time of introduction.

+ 7 - 3
resource/locales/ja/translation.json

@@ -44,8 +44,10 @@
 
   "Unportalize": "ポータル解除",
 
-  "View this version": "このバージョンを見る",
-  "View diff": "差分を見る",
+  "Go to this version": "このバージョンを見る",
+  "View diff": "差分を表示",
+  "No diff": "差分なし",
+  "Shrink versions that have no diffs": "差分のないバージョンをコンパクトに表示する",
 
   "User ID": "ユーザーID",
   "Home": "ホーム",
@@ -534,7 +536,9 @@
     "Deactivate account": "アカウント停止",
     "your_own": "自分自身のアカウントを停止することはできません",
     "Administrator menu": "管理者メニュー",
-    "cannot_remove": "自分自身を管理者から外すことはできません"
+    "cannot_remove": "自分自身を管理者から外すことはできません",
+    "cannot_invite_maximum_users": "ユーザーが上限に達したため招待できません。",
+    "current users": "現在のユーザー数:"
   },
 
   "importer_management": {

+ 4 - 1
src/client/js/app.js

@@ -558,5 +558,8 @@ socket.on('page:editingWithHackmd', function(data) {
 
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
-  ReactDOM.render(<PageHistory pageId={pageId} crowi={crowi} />, document.getElementById('revision-history'));
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <PageHistory pageId={pageId} crowi={crowi} />
+    </I18nextProvider>, document.getElementById('revision-history'));
 });

+ 73 - 56
src/client/js/components/PageComment/CommentForm.js → src/client/js/components/PageComment/CommentForm.jsx

@@ -11,7 +11,7 @@ import * as toastr from 'toastr';
 import GrowiRenderer from '../../util/GrowiRenderer';
 
 import Editor from '../PageEditor/Editor';
-import CommentPreview from '../PageComment/CommentPreview';
+import CommentPreview from './CommentPreview';
 import SlackNotification from '../SlackNotification';
 
 /**
@@ -33,6 +33,7 @@ export default class CommentForm extends React.Component {
     const isUploadableFile = config.upload.file;
 
     this.state = {
+      isFormShown: false,
       comment: '',
       isMarkdown: true,
       html: '',
@@ -56,6 +57,7 @@ export default class CommentForm extends React.Component {
     this.onUpload = this.onUpload.bind(this);
     this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
     this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
+    this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
   }
 
   updateState(value) {
@@ -210,6 +212,10 @@ export default class CommentForm extends React.Component {
     });
   }
 
+  showCommentFormBtnClickHandler() {
+    this.setState({ isFormShown: true });
+  }
+
   renderControls() {
 
   }
@@ -225,13 +231,14 @@ export default class CommentForm extends React.Component {
 
     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">
+      <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">
@@ -241,68 +248,78 @@ export default class CommentForm extends React.Component {
                 </a>
               </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="editor"
-                        value={this.state.comment}
-                        isGfmMode={this.state.isMarkdown}
-                        editorOptions={this.props.editorOptions}
-                        lineNumbers={false}
-                        isMobile={this.props.crowi.isMobile}
-                        isUploadable={this.state.isUploadable}
-                        isUploadableFile={this.state.isUploadableFile}
-                        emojiStrategy={emojiStrategy}
-                        onChange={this.updateState}
-                        onUpload={this.onUpload}
-                        onCtrlEnter={this.postComment}
-                      />
-                    </Tab>
-                    { this.state.isMarkdown == true &&
-                    <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}}>
-                    { this.state.key == 1 &&
-                      <span>
-                        <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
-                      </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
-                          crowi={this.props.crowi}
-                          pageId={this.props.pageId}
-                          pagePath={this.props.pagePath}
-                          isSlackEnabled={this.state.isSlackEnabled}
-                          slackChannels={this.state.slackChannels}
-                          onEnabledFlagChange={this.onSlackEnabledFlagChange}
-                          onChannelChange={this.onSlackChannelsChange}
+                {/* Add Comment Button */}
+                { !this.state.isFormShown &&
+                  <button className="btn btn-lg btn-link 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="editor"
+                          value={this.state.comment}
+                          isGfmMode={this.state.isMarkdown}
+                          editorOptions={this.props.editorOptions}
+                          lineNumbers={false}
+                          isMobile={this.props.crowi.isMobile}
+                          isUploadable={this.state.isUploadable}
+                          isUploadableFile={this.state.isUploadableFile}
+                          emojiStrategy={emojiStrategy}
+                          onChange={this.updateState}
+                          onUpload={this.onUpload}
+                          onCtrlEnter={this.postComment}
                         />
-                      </div>
-                    }
-                    <div className="hidden-xs">{submitButton}</div>
+                      </Tab>
+                      { this.state.isMarkdown == true &&
+                      <Tab eventKey={2} title="Preview">
+                        <div className="comment-form-preview">
+                        {commentPreview}
+                        </div>
+                      </Tab>
+                      }
+                    </Tabs>
                   </div>
-                  <div className="visible-xs mt-2">
-                    <div className="d-flex justify-content-end">
-                      { this.state.errorMessage && errorMessage }
-                      <div>{submitButton}</div>
+                  <div className="comment-submit">
+                    <div className="d-flex">
+                      <label style={{flex: 1}}>
+                      { this.state.key == 1 &&
+                        <span>
+                          <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
+                        </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
+                            crowi={this.props.crowi}
+                            pageId={this.props.pageId}
+                            pagePath={this.props.pagePath}
+                            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>
+                </React.Fragment>}
               </div>
             </div>
           }
         </form>
+
       </div>
     );
   }

+ 6 - 6
src/client/js/components/PageEditor/Editor.js → src/client/js/components/PageEditor/Editor.jsx

@@ -15,6 +15,7 @@ export default class Editor extends AbstractEditor {
     super(props);
 
     this.state = {
+      isComponentDidMount: false,
       dropzoneActive: false,
       isUploading: false,
     };
@@ -32,6 +33,10 @@ export default class Editor extends AbstractEditor {
     this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
   }
 
+  componentDidMount() {
+    this.setState({ isComponentDidMount: true });
+  }
+
   getEditorSubstance() {
     return this.props.isMobile
       ? this.refs.taEditor
@@ -207,11 +212,6 @@ export default class Editor extends AbstractEditor {
   }
 
   getNavbarItems() {
-    // wait for rendering CodeMirrorEditor or TextAreaEditor
-    if (this.getEditorSubstance() == null) {
-      return null;
-    }
-
     // set navbar items(react elements) here that are common in CodeMirrorEditor or TextAreaEditor
     const navbarItems = [];
 
@@ -245,7 +245,7 @@ export default class Editor extends AbstractEditor {
 
           { this.state.dropzoneActive && this.renderDropzoneOverlay() }
 
-          { this.renderNavbar() }
+          { this.state.isComponentDidMount && this.renderNavbar() }
 
           {/* for PC */}
           { !isMobile &&

+ 206 - 75
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -3,20 +3,28 @@ import PropTypes from 'prop-types';
 
 import Modal from 'react-bootstrap/es/Modal';
 import Button from 'react-bootstrap/es/Button';
-import Navbar from 'react-bootstrap/es/Navbar';
 import ButtonGroup from 'react-bootstrap/es/ButtonGroup';
 
 import { debounce } from 'throttle-debounce';
+import Collapse from 'react-bootstrap/es/Collapse';
+import FormGroup from 'react-bootstrap/es/FormGroup';
+import ControlLabel from 'react-bootstrap/es/ControlLabel';
+import FormControl from 'react-bootstrap/es/FormControl';
 
 import Handsontable from 'handsontable';
 import { HotTable } from '@handsontable/react';
 
 import MarkdownTable from '../../models/MarkdownTable';
-import HandsontableUtil from './HandsontableUtil';
 
 const DEFAULT_HOT_HEIGHT = 300;
+const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
+  'r': 'htRight',
+  'c': 'htCenter',
+  'l': 'htLeft',
+  '': ''
+};
 
-export default class HandsontableModal extends React.Component {
+export default class HandsontableModal extends React.PureComponent {
 
 
   constructor(props) {
@@ -24,22 +32,39 @@ export default class HandsontableModal extends React.Component {
 
     this.state = {
       show: false,
+      isDataImportAreaExpanded: false,
       isWindowExpanded: false,
       markdownTableOnInit: HandsontableModal.getDefaultMarkdownTable(),
       markdownTable: HandsontableModal.getDefaultMarkdownTable(),
       handsontableHeight: DEFAULT_HOT_HEIGHT,
-      handsontableSetting: HandsontableModal.getDefaultHandsontableSetting()
     };
 
     this.init = this.init.bind(this);
     this.reset = this.reset.bind(this);
     this.cancel = this.cancel.bind(this);
     this.save = this.save.bind(this);
+    this.afterLoadDataHandler = this.afterLoadDataHandler.bind(this);
+    this.beforeColumnMoveHandler = this.beforeColumnMoveHandler.bind(this);
+    this.beforeColumnResizeHandler = this.beforeColumnResizeHandler.bind(this);
+    this.afterColumnResizeHandler = this.afterColumnResizeHandler.bind(this);
+    this.modifyColWidthHandler = this.modifyColWidthHandler.bind(this);
+    this.synchronizeAlignment = this.synchronizeAlignment.bind(this);
+    this.alignButtonHandler = this.alignButtonHandler.bind(this);
+    this.toggleDataImportArea = this.toggleDataImportArea.bind(this);
     this.expandWindow = this.expandWindow.bind(this);
     this.contractWindow = this.contractWindow.bind(this);
 
     // create debounced method for expanding HotTable
     this.expandHotTableHeightWithDebounce = debounce(100, this.expandHotTableHeight);
+
+    // a Set instance that stores column indices which are resized manually.
+    // these columns will NOT be determined the width automatically by 'modifyColWidthHandler'
+    this.manuallyResizedColumnIndicesSet = new Set();
+
+    // generate setting object for HotTable instance
+    this.handsontableSettings = Object.assign(HandsontableModal.getDefaultHandsontableSetting(), {
+      contextMenu: this.createCustomizedContextMenu(),
+    });
   }
 
   init(markdownTable) {
@@ -48,19 +73,42 @@ export default class HandsontableModal extends React.Component {
       {
         markdownTableOnInit: initMarkdownTable,
         markdownTable: initMarkdownTable.clone(),
-        handsontableSetting: Object.assign({}, this.state.handsontableSetting, {
-          /*
-           * The afterUpdateSettings hook is called when this component state changes.
-           *
-           * In detail, when this component state changes, React will re-render HotTable because it is passed some state values of this component.
-           * HotTable#shouldComponentUpdate is called in this process and it call the updateSettings method for the Handsontable instance.
-           * After updateSetting is executed, Handsontable calls a AfterUpdateSetting hook.
-           */
-          //// commented out and will be fixed by GC-1203 -- 2018.10.19 Yuki Takei
-          // afterUpdateSettings: HandsontableUtil.createHandlerToSynchronizeHandontableAlignWith(initMarkdownTable.options.align)
-        })
       }
     );
+
+    this.manuallyResizedColumnIndicesSet.clear();
+  }
+
+  createCustomizedContextMenu() {
+    return {
+      items: {
+        'row_above': {}, 'row_below': {}, 'col_left': {}, 'col_right': {},
+        'separator1': Handsontable.plugins.ContextMenu.SEPARATOR,
+        'remove_row': {}, 'remove_col': {},
+        'separator2': Handsontable.plugins.ContextMenu.SEPARATOR,
+        'custom_alignment': {
+          name: 'Align columns',
+          key: 'align_columns',
+          submenu: {
+            items: [
+              {
+                name: 'Left',
+                key: 'align_columns:1',
+                callback: (key, selection) => {this.align('l', selection[0].start.col, selection[0].end.col)}
+              }, {
+                name: 'Center',
+                key: 'align_columns:2',
+                callback: (key, selection) => {this.align('c', selection[0].start.col, selection[0].end.col)}
+              }, {
+                name: 'Right',
+                key: 'align_columns:3',
+                callback: (key, selection) => {this.align('r', selection[0].start.col, selection[0].end.col)}
+              }
+            ]
+          }
+        }
+      }
+    };
   }
 
   show(markdownTable) {
@@ -68,27 +116,116 @@ export default class HandsontableModal extends React.Component {
     this.setState({ show: true });
   }
 
+  hide() {
+    this.setState({
+      show: false,
+      isDataImportAreaExpanded: false,
+      isWindowExpanded: false,
+    });
+  }
+
   reset() {
     this.setState({ markdownTable: this.state.markdownTableOnInit.clone() });
   }
 
   cancel() {
-    this.setState({ show: false });
+    this.hide();
   }
 
   save() {
-    let newMarkdownTable = this.state.markdownTable.clone();
-    //// commented out and will be fixed by GC-1203 -- 2018.10.19 Yuki Takei
-    // newMarkdownTable.options.align = HandsontableUtil.getMarkdownTableAlignmentFrom(this.refs.hotTable.hotInstance);
-
     if (this.props.onSave != null) {
-      this.props.onSave(newMarkdownTable);
+      this.props.onSave(this.state.markdownTable);
+    }
+
+    this.hide();
+  }
+
+  afterLoadDataHandler(initialLoad) {
+    // clear 'manuallyResizedColumnIndicesSet' for the first loading
+    if (initialLoad) {
+      this.manuallyResizedColumnIndicesSet.clear();
+    }
+
+    this.synchronizeAlignment();
+  }
+
+  beforeColumnMoveHandler(columns, target) {
+    // clear 'manuallyResizedColumnIndicesSet'
+    this.manuallyResizedColumnIndicesSet.clear();
+  }
+
+  beforeColumnResizeHandler(currentColumn) {
+    /*
+     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
+     * https://github.com/handsontable/handsontable/issues/3328
+     *
+     * At the moment, using 'afterColumnResizeHandler' instead.
+     */
+
+    // store column index
+    // this.manuallyResizedColumnIndicesSet.add(currentColumn);
+  }
+
+  afterColumnResizeHandler(currentColumn) {
+    /*
+     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
+     * https://github.com/handsontable/handsontable/issues/3328
+     *
+     * At the moment, using 'afterColumnResizeHandler' instead.
+     */
+
+    // store column index
+    this.manuallyResizedColumnIndicesSet.add(currentColumn);
+    // force re-render
+    const hotInstance = this.refs.hotTable.hotInstance;
+    hotInstance.render();
+  }
+
+  modifyColWidthHandler(width, column) {
+    // return original width if the column index exists in 'manuallyResizedColumnIndicesSet'
+    if (this.manuallyResizedColumnIndicesSet.has(column)) {
+      return width;
     }
+    // return fixed width if first initializing
+    return Math.max(80, Math.min(400, width));
+  }
 
-    this.setState({ show: false });
+  /**
+   * change the markdownTable alignment and synchronize the handsontable alignment to it
+   */
+  align(direction, startCol, endCol) {
+    this.setState((prevState) => {
+      // change only align info, so share table data to avoid redundant copy
+      const newMarkdownTable = new MarkdownTable(prevState.markdownTable.table, {align: [].concat(prevState.markdownTable.options.align)});
+      for (let i = startCol; i <= endCol ; i++) {
+        newMarkdownTable.options.align[i] = direction;
+      }
+      return { markdownTable: newMarkdownTable };
+    }, () => {
+      this.synchronizeAlignment();
+    });
   }
 
-  setClassNameToColumns(className) {
+  /**
+   * synchronize the handsontable alignment to the markdowntable alignment
+   */
+  synchronizeAlignment() {
+    if (this.refs.hotTable == null) {
+      return;
+    }
+
+    const align = this.state.markdownTable.options.align;
+    const hotInstance = this.refs.hotTable.hotInstance;
+
+    for (let i = 0; i < align.length; i++) {
+      for (let j = 0; j < hotInstance.countRows(); j++) {
+        hotInstance.setCellMeta(j, i, 'className', MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING[align[i]]);
+      }
+    }
+    hotInstance.render();
+  }
+
+  alignButtonHandler(direction) {
     const selectedRange = this.refs.hotTable.hotInstance.getSelectedRange();
     if (selectedRange == null) return;
 
@@ -104,14 +241,18 @@ export default class HandsontableModal extends React.Component {
       endCol = selectedRange[0].from.col;
     }
 
-    HandsontableUtil.setClassNameToColumns(this.refs.hotTable.hotInstance, startCol, endCol, className);
+    this.align(direction, startCol, endCol);
+  }
+
+  toggleDataImportArea() {
+    this.setState({ isDataImportAreaExpanded: !this.state.isDataImportAreaExpanded });
   }
 
   expandWindow() {
     this.setState({ isWindowExpanded: true });
 
     // invoke updateHotTableHeight method with delay
-    // cz. Resizing this.refs.hotTableContainer is completeted after a little delay after 'isWindowExpanded' set with 'true'
+    // cz. Resizing this.refs.hotTableContainer is completed after a little delay after 'isWindowExpanded' set with 'true'
     this.expandHotTableHeightWithDebounce();
   }
 
@@ -155,19 +296,47 @@ export default class HandsontableModal extends React.Component {
           <Modal.Title>Edit Table</Modal.Title>
         </Modal.Header>
         <Modal.Body className="p-0 d-flex flex-column">
-          <Navbar className="mb-0">
-            <Navbar.Form>
-              {/* commented out and will be fixed by GC-1203 -- 2018.10.19 Yuki Takei
-              <ButtonGroup>
-                <Button onClick={() => { this.setClassNameToColumns('htLeft') }}><i className="ti-align-left"></i></Button>
-                <Button onClick={() => { this.setClassNameToColumns('htCenter') }}><i className="ti-align-center"></i></Button>
-                <Button onClick={() => { this.setClassNameToColumns('htRight') }}><i className="ti-align-right"></i></Button>
-              </ButtonGroup>
-              */}
-            </Navbar.Form>
-          </Navbar>
+          <div className="px-4 py-3 modal-navbar">
+            <Button className="m-r-20 data-import-button" onClick={this.toggleDataImportArea}>
+              (TBD) Data Import<i className={this.state.isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down' }></i>
+            </Button>
+            <ButtonGroup>
+              <Button onClick={() => { this.alignButtonHandler('l') }}><i className="ti-align-left"></i></Button>
+              <Button onClick={() => { this.alignButtonHandler('c') }}><i className="ti-align-center"></i></Button>
+              <Button onClick={() => { this.alignButtonHandler('r') }}><i className="ti-align-right"></i></Button>
+            </ButtonGroup>
+            <Collapse in={this.state.isDataImportAreaExpanded}>
+              <div> {/* This div is necessary for smoothing animations. (https://react-bootstrap.github.io/utilities/transitions/#transitions-collapse) */}
+                <form action="" className="data-import-form pt-5">
+                  <FormGroup>
+                    <ControlLabel>Select Data Format</ControlLabel>
+                    <FormControl componentClass="select" placeholder="select">
+                      <option value="select">(TBD) CSV</option>
+                      <option value="other">(TBD) TSV</option>
+                      <option value="other">(TBD) HTML</option>
+                    </FormControl>
+                  </FormGroup>
+                  <FormGroup>
+                    <ControlLabel>Import Data</ControlLabel>
+                    <FormControl componentClass="textarea" placeholder="Paste table data" style={{ height: 200 }}  />
+                  </FormGroup>
+                  <div className="d-flex justify-content-end">
+                    <Button bsStyle="default" onClick={this.toggleDataImportArea}>Cancel</Button>
+                    <Button bsStyle="primary">(TBD) Import</Button>
+                  </div>
+                </form>
+              </div>
+            </Collapse>
+          </div>
           <div ref="hotTableContainer" className="m-4 hot-table-container">
-            <HotTable ref='hotTable' data={this.state.markdownTable.table} settings={this.state.handsontableSetting} height={this.state.handsontableHeight} />
+            <HotTable ref='hotTable' data={this.state.markdownTable.table}
+                settings={this.handsontableSettings} height={this.state.handsontableHeight}
+                afterLoadData={this.afterLoadDataHandler}
+                modifyColWidth={this.modifyColWidthHandler}
+                beforeColumnMove={this.beforeColumnMoveHandler}
+                beforeColumnResize={this.beforeColumnResizeHandler}
+                afterColumnResize={this.afterColumnResizeHandler}
+              />
           </div>
         </Modal.Body>
         <Modal.Footer>
@@ -206,44 +375,6 @@ export default class HandsontableModal extends React.Component {
       manualColumnResize: true,
       selectionMode: 'multiple',
       outsideClickDeselects: false,
-
-      modifyColWidth: function(width) {
-        return Math.max(80, Math.min(400, width));
-      },
-
-      contextMenu: {
-        items: {
-          'row_above': {}, 'row_below': {}, 'col_left': {}, 'col_right': {},
-          'separator1': Handsontable.plugins.ContextMenu.SEPARATOR,
-          'remove_row': {}, 'remove_col': {},
-          'separator2': Handsontable.plugins.ContextMenu.SEPARATOR,
-          //// commented out and will be fixed by GC-1203 -- 2018.10.19 Yuki Takei
-          // 'custom_alignment': {
-          //   name: 'Align columns',
-          //   key: 'align_columns',
-          //   submenu: {
-          //     items: [{
-          //       name: 'Left',
-          //       key: 'align_columns:1',
-          //       callback: function(key, selection) {
-          //         HandsontableUtil.setClassNameToColumns(this, selection[0].start.col, selection[0].end.col, 'htLeft');
-          //       }}, {
-          //       name: 'Center',
-          //       key: 'align_columns:2',
-          //       callback: function(key, selection) {
-          //         HandsontableUtil.setClassNameToColumns(this, selection[0].start.col, selection[0].end.col, 'htCenter');
-          //       }}, {
-          //       name: 'Right',
-          //       key: 'align_columns:3',
-          //       callback: function(key, selection) {
-          //         HandsontableUtil.setClassNameToColumns(this, selection[0].start.col, selection[0].end.col, 'htRight');
-          //       }}
-          //     ]
-          //   }
-          // }
-        }
-      }
-
     };
   }
 }

+ 0 - 54
src/client/js/components/PageEditor/HandsontableUtil.js

@@ -1,54 +0,0 @@
-/**
- * Utility for Handsontable (and cooperation with MarkdownTable)
- */
-export default class HandsontableUtil {
-
-  static setClassNameToColumns(core, startCol, endCol, className) {
-    for (let i = startCol; i <= endCol; i++) {
-      for (let j = 0; j < core.countRows(); j++) {
-        core.setCellMeta(j, i, 'className', className);
-      }
-    }
-    core.render();
-  }
-
-  /**
-   * return a function(handsontable event handler) to adjust the handsontable alignment to the markdown table
-   */
-  static createHandlerToSynchronizeHandontableAlignWith(markdownTableAlign) {
-    const mapping = {
-      'r': 'htRight',
-      'c': 'htCenter',
-      'l': 'htLeft',
-      '': ''
-    };
-
-    return function() {
-      const align = markdownTableAlign;
-      for (let i = 0; i < align.length; i++) {
-        HandsontableUtil.setClassNameToColumns(this, i, i, mapping[align[i]]);
-      }
-    };
-  }
-
-  /**
-   * return MarkdownTable alignment retrieved from Handsontable instance
-   */
-  static getMarkdownTableAlignmentFrom(handsontable) {
-    const cellMetasAtFirstRow = handsontable.getCellMetaAtRow(0);
-    const mapping = {
-      'htRight': 'r',
-      'htCenter': 'c',
-      'htLeft': 'l',
-      '': ''
-    };
-
-    let align = [];
-    for (let i = 0; i < cellMetasAtFirstRow.length; i++) {
-      align.push(mapping[cellMetasAtFirstRow[i].className]);
-    }
-
-    return align;
-  }
-}
-

+ 6 - 1
src/client/js/components/PageHistory.js

@@ -1,9 +1,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
 
 import PageRevisionList from './PageHistory/PageRevisionList';
 
-export default class PageHistory extends React.Component {
+class PageHistory extends React.Component {
 
   constructor(props) {
     super(props);
@@ -119,6 +120,7 @@ export default class PageHistory extends React.Component {
     return (
       <div>
         <PageRevisionList
+          t={this.props.t}
           revisions={this.state.revisions}
           diffOpened={this.state.diffOpened}
           getPreviousRevision={this.getPreviousRevision}
@@ -130,6 +132,9 @@ export default class PageHistory extends React.Component {
 }
 
 PageHistory.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
   pageId: PropTypes.string,
   crowi: PropTypes.object.isRequired,
 };
+
+export default translate()(PageHistory);

+ 0 - 57
src/client/js/components/PageHistory/PageRevisionList.js

@@ -1,57 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import Revision     from './Revision';
-import RevisionDiff from './RevisionDiff';
-
-export default class PageRevisionList extends React.Component {
-
-  render() {
-    const revisions = this.props.revisions,
-      revisionCount = this.props.revisions.length;
-
-    const revisionList = this.props.revisions.map((revision, idx) => {
-      const revisionId = revision._id
-        , revisionDiffOpened = this.props.diffOpened[revisionId] || false;
-
-
-      let previousRevision;
-      if (idx+1 < revisionCount) {
-        previousRevision = revisions[idx + 1];
-      }
-      else {
-        previousRevision = revision; // if it is the first revision, show full text as diff text
-      }
-
-      return (
-        <div className="revision-hisory-outer" key={'revision-history-' + revisionId}>
-          <Revision
-            revision={revision}
-            revisionDiffOpened={revisionDiffOpened}
-            onDiffOpenClicked={this.props.onDiffOpenClicked}
-            key={'revision-history-rev-' + revisionId}
-            />
-          <RevisionDiff
-            revisionDiffOpened={revisionDiffOpened}
-            currentRevision={revision}
-            previousRevision={previousRevision}
-            key={'revision-diff-' + revisionId}
-          />
-        </div>
-      );
-    });
-
-    return (
-      <div className="revision-history-list">
-        {revisionList}
-      </div>
-    );
-  }
-}
-
-PageRevisionList.propTypes = {
-  revisions: PropTypes.array,
-  diffOpened: PropTypes.object,
-  onDiffOpenClicked: PropTypes.func.isRequired,
-};
-

+ 111 - 0
src/client/js/components/PageHistory/PageRevisionList.jsx

@@ -0,0 +1,111 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Revision     from './Revision';
+import RevisionDiff from './RevisionDiff';
+
+export default class PageRevisionList extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isCompactNodiffRevisions: true,
+    };
+
+    this.cbCompactizeChangeHandler = this.cbCompactizeChangeHandler.bind(this);
+  }
+
+  cbCompactizeChangeHandler() {
+    this.setState({ isCompactNodiffRevisions: !this.state.isCompactNodiffRevisions });
+  }
+
+  /**
+   * render a row (Revision component and RevisionDiff component)
+   * @param {Revison} revision
+   * @param {Revision} previousRevision
+   * @param {boolean} hasDiff whether revision has difference to previousRevision
+   * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
+   */
+  renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
+    const revisionId = revision._id;
+    const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
+
+    const classNames = ['revision-history-outer'];
+    if (isContiguousNodiff) {
+      classNames.push('revision-history-outer-contiguous-nodiff');
+    }
+
+    return (
+      <div className={classNames.join(' ')} key={`revision-history-${revisionId}`}>
+        <Revision
+          t={this.props.t}
+          revision={revision}
+          revisionDiffOpened={revisionDiffOpened}
+          hasDiff={hasDiff}
+          isCompactNodiffRevisions={this.state.isCompactNodiffRevisions}
+          onDiffOpenClicked={this.props.onDiffOpenClicked}
+          key={`revision-history-rev-${revisionId}`}
+          />
+        { hasDiff &&
+          <RevisionDiff
+            revisionDiffOpened={revisionDiffOpened}
+            currentRevision={revision}
+            previousRevision={previousRevision}
+            key={`revision-deff-${revisionId}`}
+          />
+        }
+      </div>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+
+    const revisions = this.props.revisions,
+      revisionCount = this.props.revisions.length;
+
+    let hasDiffPrev;
+
+    const revisionList = this.props.revisions.map((revision, idx) => {
+      let previousRevision;
+      if (idx+1 < revisionCount) {
+        previousRevision = revisions[idx + 1];
+      }
+      else {
+        previousRevision = revision; // if it is the first revision, show full text as diff text
+      }
+
+      const hasDiff = revision.hasDiffToPrev !== false; // set 'true' if undefined for backward compatibility
+      const isContiguousNodiff = !hasDiff && !hasDiffPrev;
+
+      hasDiffPrev = hasDiff;
+
+      return this.renderRow(revision, previousRevision, hasDiff, isContiguousNodiff);
+    });
+
+    const classNames = ['revision-history-list'];
+    if (this.state.isCompactNodiffRevisions) {
+      classNames.push('revision-history-list-compact');
+    }
+
+    return <React.Fragment>
+      <div className='checkbox checkbox-info pull-right'>
+        <input id='cbCompactize' type='checkbox' value={true} checked={this.state.isCompactNodiffRevisions} onChange={this.cbCompactizeChangeHandler}></input>
+        <label htmlFor='cbCompactize'>{ t('Shrink versions that have no diffs') }</label>
+      </div>
+      <div className="clearfix"></div>
+      <div className={classNames.join(' ')}>
+        {revisionList}
+      </div>
+    </React.Fragment>;
+  }
+}
+
+PageRevisionList.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
+  revisions: PropTypes.array,
+  diffOpened: PropTypes.object,
+  onDiffOpenClicked: PropTypes.func.isRequired,
+};
+

+ 0 - 65
src/client/js/components/PageHistory/Revision.js

@@ -1,65 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import UserDate     from '../Common/UserDate';
-import UserPicture  from '../User/UserPicture';
-
-export default class Revision extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this._onDiffOpenClicked = this._onDiffOpenClicked.bind(this);
-  }
-
-  componentDidMount() {
-  }
-
-  _onDiffOpenClicked() {
-    this.props.onDiffOpenClicked(this.props.revision);
-  }
-
-  render() {
-    const revision = this.props.revision;
-    const author = revision.author;
-
-    let pic = '';
-    if (typeof author === 'object') {
-      pic = <UserPicture user={author} />;
-    }
-
-    const iconClass = this.props.revisionDiffOpened ? 'caret caret-opened' : 'caret';
-    return (
-      <div className="revision-history-main d-flex">
-        <div className="m-t-5">
-          {pic}
-        </div>
-        <div className="m-l-10">
-          <div className="revision-history-author">
-            <strong>{author.username}</strong>
-          </div>
-          <div className="revision-history-meta">
-            <p>
-              <UserDate dateTime={revision.createdAt} />
-            </p>
-            <p>
-              <a className="diff-view" onClick={this._onDiffOpenClicked}>
-                <i className={iconClass}></i> View diff
-              </a>
-              <a href={'?revision=' + revision._id } className="m-l-10">
-                <i className="icon-login"></i> Go to this version
-              </a>
-            </p>
-          </div>
-        </div>
-      </div>
-    );
-  }
-}
-
-Revision.propTypes = {
-  revision: PropTypes.object,
-  revisionDiffOpened: PropTypes.bool.isRequired,
-  onDiffOpenClicked: PropTypes.func.isRequired,
-};
-

+ 113 - 0
src/client/js/components/PageHistory/Revision.jsx

@@ -0,0 +1,113 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import UserDate     from '../Common/UserDate';
+import UserPicture  from '../User/UserPicture';
+
+export default class Revision extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this._onDiffOpenClicked = this._onDiffOpenClicked.bind(this);
+  }
+
+  componentDidMount() {
+  }
+
+  _onDiffOpenClicked() {
+    this.props.onDiffOpenClicked(this.props.revision);
+  }
+
+  renderSimplifiedNodiff(revision) {
+    const { t } = this.props;
+
+    const author = revision.author;
+
+    let pic = '';
+    if (typeof author === 'object') {
+      pic = <UserPicture user={author} size='sm' />;
+    }
+
+    return (
+      <div className="revision-history-main revision-history-main-nodiff my-1 d-flex align-items-center">
+        <div className="picture-container">
+          {pic}
+        </div>
+        <div className="m-l-10">
+          <div className="revision-history-meta">
+            <span className="text-muted small">
+              <UserDate dateTime={revision.createdAt} /> ({ t('No diff') })
+            </span>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  renderFull(revision) {
+    const { t } = this.props;
+
+    const author = revision.author;
+
+    let pic = '';
+    if (typeof author === 'object') {
+      pic = <UserPicture user={author} size='lg' />;
+    }
+
+    const iconClass = this.props.revisionDiffOpened ? 'caret caret-opened' : 'caret';
+    return (
+      <div className="revision-history-main d-flex mt-3">
+        <div className="m-t-5">
+          {pic}
+        </div>
+        <div className="m-l-10">
+          <div className="revision-history-author">
+            <strong>{author.username}</strong>
+          </div>
+          <div className="revision-history-meta">
+            <p>
+              <UserDate dateTime={revision.createdAt} />
+            </p>
+            <p>
+              <span className='d-inline-block' style={{ minWidth: '90px' }}>
+                { !this.props.hasDiff &&
+                  <span className='text-muted'>{ t('No diff') }</span>
+                }
+                { this.props.hasDiff &&
+                  <a className="diff-view" onClick={this._onDiffOpenClicked}>
+                    <i className={iconClass}></i> { t('View diff') }
+                  </a>
+                }
+              </span>
+              <a href={'?revision=' + revision._id } className="m-l-10">
+                <i className="icon-login"></i> { t('Go to this version') }
+              </a>
+            </p>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    const revision = this.props.revision;
+
+    if (this.props.isCompactNodiffRevisions && !this.props.hasDiff) {
+      return this.renderSimplifiedNodiff(revision);
+    }
+    else {
+      return this.renderFull(revision);
+    }
+  }
+}
+
+Revision.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
+  revision: PropTypes.object,
+  revisionDiffOpened: PropTypes.bool.isRequired,
+  hasDiff: PropTypes.bool.isRequired,
+  isCompactNodiffRevisions: PropTypes.bool.isRequired,
+  onDiffOpenClicked: PropTypes.func.isRequired,
+};
+

+ 1 - 0
src/client/styles/agile-admin/inverse/colors/_apply-colors.scss

@@ -309,6 +309,7 @@ body.on-edit {
     .page-editor-editor-container {
       border-right-color: $navbar-border;
       .navbar-editor {
+        border-bottom-color: $border;
         background-color: $active-nav-tabs-bgcolor;   // same color with active tab
       }
     }

+ 26 - 0
src/client/styles/scss/_handsontable.scss

@@ -35,4 +35,30 @@
 // see https://github.com/handsontable/handsontable/issues/2937#issuecomment-287390111
 .modal.in .modal-dialog.handsontable-modal {
   transform: none;
+
+  .modal-navbar {
+    background-color: $navbar-default-bg;
+    border-bottom: $border 1px solid;
+  }
+
+  .data-import-form {
+    background-color: #f8f8f8;
+
+    .btn + .btn {
+      margin-left: 5px;
+    }
+  }
+
+  .data-import-button {
+    position: relative;
+    padding-right: 35px;
+    padding-left: 10px;
+
+    i:before {
+      position: absolute;
+      top: 6px;
+      right: 8px;
+      font-size: 20px;
+    }
+  }
 }

+ 4 - 0
src/client/styles/scss/_on-edit.scss

@@ -166,6 +166,10 @@ body.on-edit {
         }
       }
 
+      .navbar-editor {
+        border-bottom: 1px solid transparent;
+      }
+
       // add icon on cursor
       .autoformat-markdown-table-activated .CodeMirror-cursor {
         &:after {

+ 19 - 2
src/client/styles/scss/_page.scss

@@ -85,9 +85,14 @@
 .main-container .main .content-main .revision-history { // {{{
 
   .revision-history-list {
-    .revision-hisory-outer {
+    .revision-history-outer {
+      // add border-top except of first element
+      &:not(:first-of-type) {
+        border-top: 1px solid $border;
+      }
+
       .revision-history-main {
-        .picture {
+        .picture-lg {
           width: 32px;
           height: 32px;
         }
@@ -106,6 +111,12 @@
           }
         }
       }
+      .revision-history-main-nodiff {
+        .picture-container {
+          min-width: 32px;
+          text-align: center; // centering .picture
+        }
+      }
       .revision-history-diff {
         padding-left: 40px;
       }
@@ -116,6 +127,12 @@
       list-style: none;
     }
   }
+  // compacted list
+  .revision-history-list-compact {
+    .revision-history-outer-contiguous-nodiff {
+      border-top: unset !important; // force unset border
+    }
+  }
 
   // adjust
   // this is for diff2html. hide page name from diff view

+ 8 - 0
src/server/crowi/express-init.js

@@ -109,6 +109,14 @@ module.exports = function(crowi, app) {
       return next();
     }
 
+    // FIXME:
+    //   healthcheck endpoint exclude from basic authentication.
+    //   however, hard coding is not desirable.
+    //   need refactoring (ex. setting basic authentication for each routes)
+    if (req.path === '/_api/v3/healthcheck') {
+      return next();
+    }
+
     if (config.crowi['security:basicName'] && config.crowi['security:basicSecret']) {
       return basicAuth(
         config.crowi['security:basicName'],

+ 3 - 3
src/server/form/invited.js

@@ -1,10 +1,10 @@
 'use strict';
 
-var form = require('express-form')
-  , field = form.field;
+const form = require('express-form');
+const field = form.field;
 
 module.exports = form(
-  field('invitedForm.username').required().is(/^[\da-zA-Z\-_\.]+$/),
+  field('invitedForm.username').required().is(/^[\da-zA-Z\-_.]+$/),
   field('invitedForm.name').required(),
   field('invitedForm.password').required().is(/^[\x20-\x7F]{6,}$/)
 );

+ 3 - 3
src/server/form/login.js

@@ -1,9 +1,9 @@
 'use strict';
 
-var form = require('express-form')
-  , field = form.field;
+const form = require('express-form');
+const field = form.field;
 
 module.exports = form(
   field('loginForm.username').required(),
-  field('loginForm.password').required().is(/^[\x20-\x7F]{6,}$/)
+  field('loginForm.password').required()
 );

+ 2 - 2
src/server/form/me/password.js

@@ -1,7 +1,7 @@
 'use strict';
 
-var form = require('express-form')
-  , field = form.field;
+const form = require('express-form');
+const field = form.field;
 
 module.exports = form(
   field('mePassword.oldPassword'),

+ 3 - 3
src/server/form/register.js

@@ -1,10 +1,10 @@
 'use strict';
 
-var form = require('express-form')
-  , field = form.field;
+const form = require('express-form');
+const field = form.field;
 
 module.exports = form(
-  field('registerForm.username').required().is(/^[\da-zA-Z\-_\.]+$/),
+  field('registerForm.username').required().is(/^[\da-zA-Z\-_.]+$/),
   field('registerForm.name').required(),
   field('registerForm.email').required(),
   field('registerForm.password').required().is(/^[\x20-\x7F]{6,}$/),

+ 10 - 0
src/server/models/config.js

@@ -610,6 +610,16 @@ module.exports = function(crowi) {
     return local_config;
   };
 
+  configSchema.statics.userUpperLimit = function(crowi) {
+    const key = 'USER_UPPER_LIMIT';
+    const env = crowi.env[key];
+
+    if (undefined === crowi.env || undefined === crowi.env[key]) {
+      return 0;
+    }
+    return Number(env);
+  };
+
   /*
   configSchema.statics.isInstalled = function(config)
   {

+ 6 - 2
src/server/models/revision.js

@@ -15,7 +15,8 @@ module.exports = function(crowi) {
     }},
     format: { type: String, default: 'markdown' },
     author: { type: ObjectId, ref: 'User' },
-    createdAt: { type: Date, default: Date.now }
+    createdAt: { type: Date, default: Date.now },
+    hasDiffToPrev: { type: Boolean },
   });
 
   /*
@@ -80,7 +81,7 @@ module.exports = function(crowi) {
 
   revisionSchema.statics.findRevisionIdList = function(path) {
     return this.find({path: path})
-      .select('_id author createdAt')
+      .select('_id author createdAt hasDiffToPrev')
       .sort({createdAt: -1})
       .exec();
   };
@@ -135,6 +136,9 @@ module.exports = function(crowi) {
     newRevision.format = format;
     newRevision.author = user._id;
     newRevision.createdAt = Date.now();
+    if (pageData.revision != null) {
+      newRevision.hasDiffToPrev = body !== pageData.revision.body;
+    }
 
     return newRevision;
   };

+ 51 - 13
src/server/models/user.js

@@ -223,14 +223,18 @@ module.exports = function(crowi) {
     return this.updateGoogleId(null, callback);
   };
 
-  userSchema.methods.activateInvitedUser = function(username, name, password, callback) {
+  userSchema.methods.activateInvitedUser = async function(username, name, password) {
     this.setPassword(password);
     this.name = name;
     this.username = username;
     this.status = STATUS_ACTIVE;
+
     this.save(function(err, userData) {
       userEvent.emit('activated', userData);
-      return callback(err, userData);
+      if (err) {
+        throw new Error(err);
+      }
+      return userData;
     });
   };
 
@@ -422,16 +426,16 @@ module.exports = function(crowi) {
       });
   };
 
-  userSchema.statics.findUsersWithPagination = function(options, callback) {
+  userSchema.statics.findUsersWithPagination = async function(options) {
     var sort = options.sort || {status: 1, username: 1, createdAt: 1};
 
-    this.paginate({status: { $ne: STATUS_DELETED }}, { page: options.page || 1, limit: options.limit || PAGE_ITEMS }, function(err, result) {
+    return await this.paginate({status: { $ne: STATUS_DELETED }}, { page: options.page || 1, limit: options.limit || PAGE_ITEMS }, function(err, result) {
       if (err) {
         debug('Error on pagination:', err);
-        return callback(err, null);
+        throw new Error(err);
       }
 
-      return callback(err, result);
+      return result;
     }, { sortBy: sort });
   };
 
@@ -501,16 +505,38 @@ module.exports = function(crowi) {
     });
   };
 
-  userSchema.statics.isRegisterableUsername = function(username, callback) {
+  userSchema.statics.isUserCountExceedsUpperLimit = async function() {
+    const Config = crowi.model('Config');
+    const userUpperLimit = Config.userUpperLimit(crowi);
+    if (userUpperLimit === 0) {
+      return false;
+    }
+
+    const activeUsers = await this.countListByStatus(STATUS_ACTIVE);
+    if (userUpperLimit !== 0 && userUpperLimit <= activeUsers) {
+      return true;
+    }
+
+    return false;
+  };
+
+  userSchema.statics.countListByStatus = async function(status) {
+    const User = this;
+    const conditions = {status: status};
+
+    // TODO count は非推奨。mongoose のバージョンアップ後に countDocuments に変更する。
+    return User.count(conditions);
+  };
+
+  userSchema.statics.isRegisterableUsername = async function(username) {
     var User = this;
     var usernameUsable = true;
 
-    this.findOne({username: username}, function(err, userData) {
-      if (userData) {
-        usernameUsable = false;
-      }
-      return callback(usernameUsable);
-    });
+    const userData = await this.findOne({username: username});
+    if (userData) {
+      usernameUsable = false;
+    }
+    return usernameUsable;
   };
 
   userSchema.statics.isRegisterable = function(email, username, callback) {
@@ -701,6 +727,13 @@ module.exports = function(crowi) {
     const User = this
       , newUser = new User();
 
+    // check user upper limit
+    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+    if (isUserCountExceedsUpperLimit) {
+      const err = new UserUpperLimitException();
+      return callback(err);
+    }
+
     // check email duplication because email must be unique
     const count = await this.count({ email });
     if (count > 0) {
@@ -773,6 +806,11 @@ module.exports = function(crowi) {
     return username;
   };
 
+  class UserUpperLimitException {
+    constructor() {
+      this.name = this.constructor.name;
+    }
+  }
 
   userSchema.statics.STATUS_REGISTERED  = STATUS_REGISTERED;
   userSchema.statics.STATUS_ACTIVE      = STATUS_ACTIVE;

+ 22 - 8
src/server/routes/admin.js

@@ -469,16 +469,23 @@ module.exports = function(crowi, app) {
   };
 
   actions.user = {};
-  actions.user.index = function(req, res) {
+  actions.user.index = async function(req, res) {
+    const activeUsers = await User.countListByStatus(User.STATUS_ACTIVE);
+    const Config = crowi.model('Config');
+    const userUpperLimit = Config.userUpperLimit(crowi);
+    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+
     var page = parseInt(req.query.page) || 1;
 
-    User.findUsersWithPagination({page: page}, function(err, result) {
-      const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
+    const result = await User.findUsersWithPagination({page: page});
+    const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
 
-      return res.render('admin/users', {
-        users: result.docs,
-        pager: pager
-      });
+    return res.render('admin/users', {
+      users: result.docs,
+      pager: pager,
+      activeUsers: activeUsers,
+      userUpperLimit: userUpperLimit,
+      isUserCountExceedsUpperLimit: isUserCountExceedsUpperLimit
     });
   };
 
@@ -534,7 +541,14 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.user.activate = function(req, res) {
+  actions.user.activate = async function(req, res) {
+    // check user upper limit
+    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+    if (isUserCountExceedsUpperLimit) {
+      req.flash('errorMessage', 'ユーザーが上限に達したため有効化できません。');
+      return res.redirect('/admin/users');
+    }
+
     var id = req.params.id;
     User.findById(id, function(err, userData) {
       userData.statusActivate(function(err, userData) {

+ 4 - 0
src/server/routes/login-passport.js

@@ -415,6 +415,10 @@ module.exports = function(crowi, app) {
           return;
         }
       }
+      else if (err.name === 'UserUpperLimitException') {
+        req.flash('warningMessage', 'Can not register more than the maximum number of users.');
+        return;
+      }
     }
   };
 

+ 27 - 17
src/server/routes/login.js

@@ -188,7 +188,12 @@ module.exports = function(crowi, app) {
 
         User.createUserByEmailAndPassword(name, username, email, password, lang, function(err, userData) {
           if (err) {
-            req.flash('registerWarningMessage', 'Failed to register.');
+            if (err.name === 'UserUpperLimitException') {
+              req.flash('registerWarningMessage', 'Can not register more than the maximum number of users.');
+            }
+            else {
+              req.flash('registerWarningMessage', 'Failed to register.');
+            }
             return res.redirect('/register');
           }
           else {
@@ -328,7 +333,7 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.invited = function(req, res) {
+  actions.invited = async function(req, res) {
     if (!req.user) {
       return res.redirect('/login');
     }
@@ -340,24 +345,29 @@ module.exports = function(crowi, app) {
       var name = invitedForm.name;
       var password = invitedForm.password;
 
-      User.isRegisterableUsername(username, function(creatable) {
-        if (creatable) {
-          user.activateInvitedUser(username, name, password, function(err, data) {
-            if (err) {
-              req.flash('warningMessage', 'アクティベートに失敗しました。');
-              return res.render('invited');
-            }
-            else {
-              return res.redirect('/');
-            }
-          });
+      // check user upper limit
+      const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+      if (isUserCountExceedsUpperLimit) {
+        req.flash('warningMessage', 'ユーザーが上限に達したためアクティベートできません。');
+        return res.redirect('/invited');
+      }
+
+      const creatable = await User.isRegisterableUsername(username);
+      if (creatable) {
+        try {
+          await user.activateInvitedUser(username, name, password);
+          return res.redirect('/');
         }
-        else {
-          req.flash('warningMessage', '利用できないユーザーIDです。');
-          debug('username', username);
+        catch (err) {
+          req.flash('warningMessage', 'アクティベートに失敗しました。');
           return res.render('invited');
         }
-      });
+      }
+      else {
+        req.flash('warningMessage', '利用できないユーザーIDです。');
+        debug('username', username);
+        return res.render('invited');
+      }
     }
     else {
       return res.render('invited', {

+ 1 - 1
src/server/views/admin/security.html

@@ -259,7 +259,7 @@
             <li>
               <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> GitHub</a>
             </li>
-            <li class="tbd">
+            <li>
               <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> Twitter</a>
             </li>
             <li class="tbd">

+ 8 - 1
src/server/views/admin/users.html

@@ -33,7 +33,7 @@
 
     <div class="col-md-9">
       <p>
-        <button data-toggle="collapse" class="btn btn-default" href="#inviteUserForm">
+        <button data-toggle="collapse" class="btn btn-default" href="#inviteUserForm" {% if isUserCountExceedsUpperLimit %}disabled{% endif %}>
           {{ t("user_management.invite_users") }}
         </button>
         <a class="btn btn-default btn-outline" href="/admin/users/external-accounts">
@@ -56,6 +56,13 @@
         <input type="hidden" name="_csrf" value="{{ csrf() }}">
       </form>
 
+      {% if isUserCountExceedsUpperLimit === true %}
+      <label>{{ t('user_management.cannot_invite_maximum_users') }}</label>
+      {% endif %}
+      {% if userUpperLimit !== 0 %}
+      <label>{{ t('user_management.current users') }}{{ activeUsers }}</label>
+      {% endif %}
+
       {% set createdUser = req.flash('createdUser') %}
       {% if createdUser.length %}
       <div class="modal fade in" id="createdUserModal">

+ 9 - 9
yarn.lock

@@ -4379,15 +4379,15 @@ i18next-browser-languagedetector@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-2.2.0.tgz#5f41abe61964a56dce70102ab31c3ed5d5866edc"
 
-i18next-express-middleware@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/i18next-express-middleware/-/i18next-express-middleware-1.1.1.tgz#9204f28c8800ac3bff87fbee01945367956f349c"
+i18next-express-middleware@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/i18next-express-middleware/-/i18next-express-middleware-1.4.1.tgz#273c4a490ad688ce246815ce1288689c63fa7de1"
   dependencies:
     cookies "0.7.1"
 
-i18next-node-fs-backend@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/i18next-node-fs-backend/-/i18next-node-fs-backend-2.0.0.tgz#ea0fc2c38523bdf8da24be00b73e3ad36ce907e3"
+i18next-node-fs-backend@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/i18next-node-fs-backend/-/i18next-node-fs-backend-2.1.0.tgz#b0ad55eb8671b4dedbd21fe434fb50e964a4ece2"
   dependencies:
     js-yaml "3.12.0"
     json5 "2.0.0"
@@ -4396,9 +4396,9 @@ i18next-sprintf-postprocessor@^0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/i18next-sprintf-postprocessor/-/i18next-sprintf-postprocessor-0.2.2.tgz#2e409f1043579382698b6a2da70cdaa551d67ea4"
 
-i18next@^11.1.1:
-  version "11.1.1"
-  resolved "https://registry.yarnpkg.com/i18next/-/i18next-11.1.1.tgz#df3a683542d7756a8aa8d6b884b61141239c394a"
+i18next@^12.0.0:
+  version "12.0.0"
+  resolved "https://registry.yarnpkg.com/i18next/-/i18next-12.0.0.tgz#27c1494219dde0451a8d714d5bfc19bf055d51bb"
 
 iconv-lite@0.4.19, iconv-lite@^0.4.17, iconv-lite@~0.4.13:
   version "0.4.19"