app/javascript/components/external/vcwiz/outreach/import_investors_modal.jsx

Summary

Maintainability
C
1 day
Test Coverage
import React from 'react';
import OverlayModal from '../global/shared/overlay_modal';
import { TargetInvestorsBulkImportPath, TargetInvestorsBulkPollPath, ImportHeadersOptions } from '../global/constants.js.erb';
import FileInput from '../global/fields/file_input';
import {ffetch, humanizeList} from '../global/utils';
import update from 'immutability-helper';
import Select from 'react-select';
import inflection from 'inflection';
import {Button, Colors} from 'react-foundation';
import { Line } from 'rc-progress';
import Table from '../global/shared/table';
import Actions from '../global/actions';
import StandardLoader from '../global/shared/standard_loader';
import hasModalErrorBoundary from '../global/shared/has_modal_error_boundary';

const Stage = {
  START: 'START',
  LOADING: 'LOADING',
  LOADED: 'LOADED',
  IMPORTING: 'IMPORTING',
  DONE: 'DONE',
  ERROR: 'ERROR',
};

@hasModalErrorBoundary
export default class ImportInvestorsModal extends React.Component {
  state = {
    stage: Stage.START,
    id: null,
    errored: [],
    samples: [],
    headerRow: null,
    headers: {},
    total: 1,
    imported: 0,
    error_message: null,
  };

  componentWillUnmount() {
    window.clearInterval(this.interval);
  }

  startPolling = (id, fn) => {
    this.interval = window.setInterval(() => {
      ffetch(TargetInvestorsBulkPollPath.id(id)).then(result => {
        if (fn(result))  {
          window.clearInterval(this.interval);
        }
      });
    }, 1000);
  };

  startLoadingPolling = id => {
    this.startPolling(id, ({id, samples, headers, header_row, total, error_message}) => {
      if (error_message) {
        this.setState({error_message, stage: Stage.ERROR});
        return true;
      }
      if (!total) {
        return false;
      }
      this.setState({id, samples, headers, total, headerRow: header_row, stage: Stage.LOADED});
      return true;
    });
  };

  startImportPolling = () => {
    this.startPolling(this.state.id, ({imported, complete, duplicates, errored}) => {
      if (imported !== this.state.imported) {
        this.setState({imported});
      }
      if (complete) {
        this.setState({errored, duplicates, stage: Stage.DONE});
        return true;
      } else {
        return false;
      }
    });
  };

  onFileUpload = file => {
    this.setState({stage: Stage.LOADING});
    ffetch(TargetInvestorsBulkImportPath, 'POST', file, { form: true }).then(
      ({id, error}) => {
        if (error) {
          this.setState({error, stage: Stage.ERROR});
        } else {
          this.startLoadingPolling(id);
        }
      }
    );
  };

  onUpdateHeader = (i) => (val) => {
    let headers;
    if (val === null) {
      headers = update(this.state.headers, {$unset: [i]});
    } else {
      headers = update(this.state.headers, {[i]: {$set: val.value}});
    }
    this.setState({headers});
  };

  onClick = () => {
    const { id, headers } = this.state;
    this.setState({stage: Stage.IMPORTING});
    ffetch(TargetInvestorsBulkImportPath, 'POST', {id, headers}).then(this.startImportPolling);
  };

  onClose = () => {
    Actions.trigger('refreshFounder');
    this.props.onClose();
  };

  options(i) {
    let used = Object.values(this.state.headers);
    let options = _.reject(ImportHeadersOptions, o => used.includes(o.value));
    if (used.includes('first_name') || used.includes('last_name')) {
      _.remove(options, {value: 'name'});
    }
    if (used.includes('name')) {
      _.remove(options, {value: 'first_name'});
      _.remove(options, {value: 'last_name'});
    }
    if (i !== -1 && this.state.headers[i]) {
      options.push(_.find(ImportHeadersOptions, {value: this.state.headers[i]}));
    }
    return options;
  }

  placeholder(i, disabled) {
    const { headerRow } = this.state;
    if (headerRow && headerRow[i]) {
      return <em>{headerRow[i]}</em>;
    } else {
      return disabled ? 'All Assigned!' : 'Select Header...';
    }
  }

  renderRemaining() {
    const options = this.options(-1);
    if (!options.length) {
      return <span>You've assigned all the columns!</span>;
    }
    return (
      <span>
        You can still assign the {humanizeList(options.map(({label}) => <b>{label}</b>))} {inflection.inflect('columns', options.length)}.
      </span>
    );
  }

  renderButton() {
    if (this.state.stage === Stage.LOADED) {
      const { total } = this.state;
      return (
        <div className="button-wrapper" key="button">
          <p>
            Below is a preview of your import. There's a total of {total} rows.
          </p>
          <p>
            We've tried to figure out which of your columns match up with ours, but we need a little help!
            Please use the dropdowns above each column to show us your setup.
            We probably won't support all your columns, and you might not have all of ours.
          </p>
          <p>
            {' '}
            {this.renderRemaining()}
            {' '}
          </p>
          <Button color={Colors.SUCCESS} onClick={this.onClick}>
            Finish Import
          </Button>
        </div>
      );
    } else if (this.state.stage === Stage.DONE) {
      return (
        <div className="button-wrapper" key="button">
          <Button onClick={this.onClose}>
            Close
          </Button>
        </div>
      );
    } else {
      return null;
    }
  }

  renderTop() {
    return _.compact([
      <h3 className="title" key="heading">Import Investor Spreadsheet</h3>,
      this.renderButton(),
    ]);
  }

  renderSamples() {
    const selects = _.range(this.state.samples[0].length).map(i => {
      const options = this.options(i);
      const disabled = options.length === 0;
      return (
        <Select
          clearable={!!this.state.headers[i]}
          value={this.state.headers[i]}
          options={options}
          onChange={this.onUpdateHeader(i)}
          disabled={disabled}
          placeholder={this.placeholder(i, disabled)}
        />
      );
    });
    return <Table headers={selects} rows={this.state.samples} headerClass="import-header" />;
  }

  renderStart() {
    return (
      <div className="main">
        <p>
          Already have a spreadsheet of investors? You can use this form to import them!
          Please export them in CSV format before uploading.
        </p>
        <FileInput
          type="file"
          name="file"
          placeholder="Import CSV"
          accept=".csv"
          onChange={this.onFileUpload}
        />
      </div>
    );
  }

  renderLoader() {
    return <StandardLoader text="Loading Preview" />;
  }

  renderProgress() {
    const { imported, total } = this.state;
    const percent = Math.round((imported / total) * 100);
    return (
      <div className="progress-wrapper">
        <h2 className="percent">{percent}%</h2>
        <Line percent={percent} strokeWidth={1} strokeColor="#2ADBC4" />
      </div>
    );
  }

  renderDuplicates() {
    const { duplicates, headerRow } = this.state;
    if (!duplicates.length) {
      return null;
    }
    return (
      <div>
        <p>There were {duplicates.length} duplicates (shown below), which were not imported.</p>
        <Table headers={headerRow} rows={duplicates} headerClass="error-header" />
      </div>
    );
  }

  renderErrors() {
    const { errored } = this.state;
    if (!errored.length) {
      return 'There were no errors.';
    } else {
      return (
        <span>
          There {inflection.inflect('were', errored.length, 'was')} {errored.length} {inflection.inflect('errors', errored.length)}.
          {' '}
          {inflection.inflect('Lines', errored.length)} {humanizeList(errored)} {inflection.inflect('are', errored.length, 'is')} invalid!
        </span>
      );
    }
  }

  renderDone() {
    const { imported } = this.state;
    return (
      <div className="main">
        We successfully imported <b>{imported}</b> investors. {this.renderErrors()}
        {this.renderDuplicates()}
      </div>
    );
  }

  renderError() {
    const { error_message } = this.state;
    return (
      <div className="main">
        <p>We're unable to complete your import, due to the following error.</p>
        <p className="error">{error_message}</p>
      </div>
    );
  }

  renderBottom() {
    switch (this.state.stage) {
      case Stage.START:
        return this.renderStart();
      case Stage.LOADING:
        return  this.renderLoader();
      case Stage.LOADED:
        return this.renderSamples();
      case Stage.IMPORTING:
        return this.renderProgress();
      case Stage.DONE:
        return this.renderDone();
      case Stage.ERROR:
        return this.renderError();
      default:
        return null;
    }
  }

  render() {
    return (
      <OverlayModal
        name="import_investors"
        top={this.renderTop()}
        bottom={this.renderBottom()}
        {...this.props}
      />
    );
  }
}