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

Merge pull request #651 from weseek/feat/handsontable-data-import

Feat/handsontable data import
Yuki Takei 7 лет назад
Родитель
Сommit
6bfead853a

+ 10 - 26
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -1,19 +1,14 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-
 import Modal from 'react-bootstrap/es/Modal';
 import Button from 'react-bootstrap/es/Button';
 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 { debounce } from 'throttle-debounce';
 
+import MarkdownTableDataImportForm from './MarkdownTableDataImportForm';
 import MarkdownTable from '../../models/MarkdownTable';
 
 const DEFAULT_HOT_HEIGHT = 300;
@@ -51,6 +46,7 @@ export default class HandsontableModal extends React.PureComponent {
     this.synchronizeAlignment = this.synchronizeAlignment.bind(this);
     this.alignButtonHandler = this.alignButtonHandler.bind(this);
     this.toggleDataImportArea = this.toggleDataImportArea.bind(this);
+    this.importData = this.importData.bind(this);
     this.expandWindow = this.expandWindow.bind(this);
     this.contractWindow = this.contractWindow.bind(this);
 
@@ -248,6 +244,11 @@ export default class HandsontableModal extends React.PureComponent {
     this.setState({ isDataImportAreaExpanded: !this.state.isDataImportAreaExpanded });
   }
 
+  importData(markdownTable) {
+    this.init(markdownTable);
+    this.toggleDataImportArea();
+  }
+
   expandWindow() {
     this.setState({ isWindowExpanded: true });
 
@@ -298,7 +299,7 @@ export default class HandsontableModal extends React.PureComponent {
         <Modal.Body className="p-0 d-flex flex-column">
           <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>
+              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>
@@ -307,24 +308,7 @@ export default class HandsontableModal extends React.PureComponent {
             </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>
+                <MarkdownTableDataImportForm onCancel={this.toggleDataImportArea} onImport={this.importData}/>
               </div>
             </Collapse>
           </div>

+ 86 - 0
src/client/js/components/PageEditor/MarkdownTableDataImportForm.jsx

@@ -0,0 +1,86 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import FormGroup from 'react-bootstrap/es/FormGroup';
+import ControlLabel from 'react-bootstrap/es/ControlLabel';
+import FormControl from 'react-bootstrap/es/FormControl';
+import Button from 'react-bootstrap/es/Button';
+import MarkdownTable from '../../models/MarkdownTable';
+import Collapse from 'react-bootstrap/es/Collapse';
+
+export default class MarkdownTableDataImportForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      dataFormat: 'csv',
+      data: '',
+      parserErrorMessage: null
+    };
+
+    this.importButtonHandler = this.importButtonHandler.bind(this);
+  }
+
+  importButtonHandler() {
+    try {
+      const markdownTable = this.convertFormDataToMarkdownTable();
+      this.props.onImport(markdownTable);
+      this.setState({parserErrorMessage: null});
+    }
+    catch (e) {
+      this.setState({parserErrorMessage: e.message});
+    }
+  }
+
+  convertFormDataToMarkdownTable() {
+    let result;
+    switch (this.state.dataFormat) {
+      case 'csv':
+        result = MarkdownTable.fromDSV(this.state.data, ',');
+        break;
+      case 'tsv':
+        result = MarkdownTable.fromDSV(this.state.data, '\t');
+        break;
+      case 'html':
+        result = MarkdownTable.fromHTMLTableTag(this.state.data);
+        break;
+    }
+    return result;
+  }
+
+  render() {
+    return (
+      <form action="" className="data-import-form pt-5">
+        <FormGroup>
+          <ControlLabel>Select Data Format</ControlLabel>
+          <FormControl componentClass="select" placeholder="select"
+                       value={this.state.dataFormat} onChange={e => this.setState({dataFormat: e.target.value})}>
+            <option value="csv">CSV</option>
+            <option value="tsv">TSV</option>
+            <option value="html">HTML</option>
+          </FormControl>
+        </FormGroup>
+        <FormGroup>
+          <ControlLabel>Import Data</ControlLabel>
+          <FormControl componentClass="textarea" placeholder="Paste table data" style={{ height: 200 }}
+                       onChange={e => this.setState({data: e.target.value})}/>
+        </FormGroup>
+        <Collapse in={this.state.parserErrorMessage != null}>
+          <FormGroup>
+            <ControlLabel>Parse Error</ControlLabel>
+            <FormControl componentClass="textarea" style={{ height: 100 }}  value={this.state.parserErrorMessage} readOnly/>
+          </FormGroup>
+        </Collapse>
+        <div className="d-flex justify-content-end">
+          <Button bsStyle="default" onClick={this.props.onCancel}>Cancel</Button>
+          <Button bsStyle="primary" onClick={this.importButtonHandler}>Import</Button>
+        </div>
+      </form>
+    );
+  }
+}
+
+MarkdownTableDataImportForm.propTypes = {
+  onCancel: PropTypes.func,
+  onImport: PropTypes.func
+};

+ 48 - 4
src/client/js/models/MarkdownTable.js

@@ -1,5 +1,6 @@
 import markdownTable from 'markdown-table';
 import stringWidth from 'string-width';
+import csvToMarkdown from 'csv-to-markdown-table';
 
 // https://github.com/markdown-it/markdown-it/blob/d29f421927e93e88daf75f22089a3e732e195bd2/lib/rules_block/table.js#L83
 // https://regex101.com/r/7BN2fR/7
@@ -7,6 +8,9 @@ const tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
 const tableAlignmentLineNegRE = /^[^-:]*$/;  // it is need to check to ignore empty row which is matched above RE
 const linePartOfTableRE = /^\|[^\r\n]*|[^\r\n]*\|$|([^|\r\n]+\|[^|\r\n]*)+/; // own idea
 
+// set up DOMParser
+const domParser = new (window.DOMParser)();
+
 /**
  * markdown table class for markdown-table module
  *   ref. https://github.com/wooorm/markdown-table
@@ -36,13 +40,53 @@ export default class MarkdownTable {
     return new MarkdownTable(newTable, this.options);
   }
 
-  static fromTableTag(str) {
-    // TODO impl
-    return new MarkdownTable();
+  /**
+   * return a MarkdownTable instance made from a string of HTML table tag
+   *
+   * If a parser error occurs, an error object with an error message is thrown.
+   * The error message is a innerHTML, so must not assign it into element.innerHTML because it can lead to Mutation-based XSS
+   */
+  static fromHTMLTableTag(str) {
+    // use DOMParser to prevent DOM based XSS (https://developer.mozilla.org/en-US/docs/Web/API/DOMParser)
+    const dom = domParser.parseFromString(str, 'application/xml');
+
+    if (dom.querySelector('parsererror')) {
+      throw new Error(dom.documentElement.innerHTML);
+    }
+
+    const tableElement = dom.querySelector('table');
+    const trElements = tableElement.querySelectorAll('tr');
+
+    let table = [];
+    let maxRowSize = 0;
+    for (let i = 0; i < trElements.length; i++) {
+      let row = [];
+      let cellElements = trElements[i].querySelectorAll('th,td');
+      for (let j = 0; j < cellElements.length; j++) {
+        row.push(cellElements[j].innerHTML);
+      }
+      table.push(row);
+
+      if (maxRowSize < row.length) maxRowSize = row.length;
+    }
+
+    let align = [];
+    for (let i = 0; i < maxRowSize; i++) {
+      align.push('');
+    }
+
+    return new MarkdownTable(table, {align: align});
+  }
+
+  /**
+   * return a MarkdownTable instance made from a string of delimiter-separated values
+   */
+  static fromDSV(str, delimiter) {
+    return MarkdownTable.fromMarkdownString(csvToMarkdown(str, delimiter, true));
   }
 
   /**
-   * returns MarkdownTable instance
+   * return a MarkdownTable instance
    *   ref. https://github.com/wooorm/markdown-table
    * @param {string} str markdown string
    */

+ 5 - 5
yarn.lock

@@ -4060,8 +4060,8 @@ gzip-size@^5.0.0:
     pify "^3.0.0"
 
 handsontable@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/handsontable/-/handsontable-6.0.1.tgz#93f07d895b42335e2882044a79bca96003a2cab2"
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/handsontable/-/handsontable-6.1.1.tgz#4be8fbe25efd3f0b85b494967475a687007e288d"
   dependencies:
     moment "2.20.1"
     numbro "^2.0.6"
@@ -6336,8 +6336,8 @@ number-is-nan@^1.0.0:
   resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
 
 numbro@^2.0.6:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/numbro/-/numbro-2.1.0.tgz#618ac6e4b2f32f2e623190ce4b05f4c8b09c3207"
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/numbro/-/numbro-2.1.1.tgz#b977fc6a769163f90e2e2d7623ff9db4d66bc661"
   dependencies:
     bignumber.js "^4.0.4"
 
@@ -6830,7 +6830,7 @@ pify@^3.0.0:
 
 pikaday@1.5.1:
   version "1.5.1"
-  resolved "https://registry.yarnpkg.com/pikaday/-/pikaday-1.5.1.tgz#0a48549bc1a14ea1d08c44074d761bc2f2bfcfd3"
+  resolved "http://registry.npmjs.org/pikaday/-/pikaday-1.5.1.tgz#0a48549bc1a14ea1d08c44074d761bc2f2bfcfd3"
   optionalDependencies:
     moment "2.x"