From ad775b6d5351f34b3f6670ac6e0c5667c3fa7b65 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Mon, 28 Oct 2019 15:19:23 -0400 Subject: [PATCH 01/29] refactor: Bundle createParser() into single export Makes loadPreview() simpler --- src/modules/import.js | 24 ++++++------------ src/utils/parsers.js | 32 ++++++++++++++++++++++- src/utils/parsers.spec.js | 53 ++++++++++++++++++--------------------- test/bad.csv | 2 -- 4 files changed, 63 insertions(+), 48 deletions(-) delete mode 100644 test/bad.csv diff --git a/src/modules/import.js b/src/modules/import.js index 07eb2c9..0375413 100644 --- a/src/modules/import.js +++ b/src/modules/import.js @@ -13,11 +13,7 @@ import { appRegistryEmit } from 'modules/compass'; import detectImportFile from 'utils/detect-import-file'; import { createCollectionWriteStream } from 'utils/collection-stream'; -import { - createCSVParser, - createJSONParser, - createProgressStream -} from 'utils/parsers'; +import createParser, { createProgressStream } from 'utils/parsers'; import createImportSizeGuesstimator from 'utils/import-size-guesstimator'; import { removeEmptyFieldsStream } from 'utils/remove-empty-fields'; @@ -287,18 +283,12 @@ export const startImport = () => { const stripBOM = stripBomStream(); const removeEmptyFields = removeEmptyFieldsStream(ignoreEmptyFields); - - let parser; - if (fileType === 'csv') { - parser = createCSVParser({ - delimiter: delimiter - }); - } else { - parser = createJSONParser({ - selector: fileIsMultilineJSON ? null : '*', - fileName: fileName - }); - } + const parser = createParser( + fileName, + fileType, + delimiter, + fileIsMultilineJSON + ); debug('executing pipeline'); diff --git a/src/utils/parsers.js b/src/utils/parsers.js index 9e393ee..85c7155 100644 --- a/src/utils/parsers.js +++ b/src/utils/parsers.js @@ -15,7 +15,7 @@ const debug = createLogger('parsers'); */ /** - * TODO: lucas: mapHeaders option to support existing `.()` caster + * TODO: lucas: csv mapHeaders option to support existing `.()` caster * like `mongoimport` does today. */ @@ -84,6 +84,8 @@ export const createJSONParser = function({ return stream; }; +// TODO: lucas: move progress to its own module? + export const createProgressStream = function(fileSize, onProgress) { const progress = progressStream({ objectMode: true, @@ -101,3 +103,31 @@ export const createProgressStream = function(fileSize, onProgress) { progress.on('progress', updateProgress); return progress; }; + +/** + * Convenience for creating the right parser transform stream in a single call. + * + * @param {String} fileName + * @param {String} fileType `csv` or `json` + * @param {String} delimiter See `createCSVParser()` + * @param {Boolean} fileIsMultilineJSON + * @returns {stream.Transform} + */ +function createParser({ + fileName = 'myfile', + fileType = 'json', + delimiter = '\n', + fileIsMultilineJSON = false +} = {}) { + if (fileType === 'csv') { + return createCSVParser({ + delimiter: delimiter + }); + } + return createJSONParser({ + selector: fileIsMultilineJSON ? null : '*', + fileName: fileName + }); +} + +export default createParser; diff --git a/src/utils/parsers.spec.js b/src/utils/parsers.spec.js index 5047074..c836612 100644 --- a/src/utils/parsers.spec.js +++ b/src/utils/parsers.spec.js @@ -2,12 +2,12 @@ import fs from 'fs'; import path from 'path'; import stream from 'stream'; -import { createCSVParser, createJSONParser } from './parsers'; +import createParser from './parsers'; const TEST_DIR = path.join(__dirname, '..', '..', '..', 'test'); const FIXTURES = { GOOD_CSV: path.join(TEST_DIR, 'good.csv'), - BAD_CSV: path.join(TEST_DIR, 'bad.csv'), + BAD_CSV: path.join(TEST_DIR, 'mongoimport', 'test_bad.csv'), JS_I_THINK_IS_JSON: path.join(TEST_DIR, 'js-i-think-is.json'), GOOD_JSON: path.join(TEST_DIR, 'docs.json'), LINE_DELIMITED_JSON: path.join(TEST_DIR, 'docs.jsonl'), @@ -16,9 +16,10 @@ const FIXTURES = { 'docs-with-newline-ending.jsonl' ) }; -function runParser(file, parser) { + +function runParser(src, parser) { const docs = []; - const source = fs.createReadStream(file); + const source = fs.createReadStream(src); const dest = new stream.Writable({ objectMode: true, write(chunk, encoding, callback) { @@ -39,50 +40,41 @@ function runParser(file, parser) { describe('parsers', () => { describe('json', () => { it('should parse a file', () => { - return runParser(FIXTURES.GOOD_JSON, createJSONParser()).then(docs => { + return runParser(FIXTURES.GOOD_JSON, createParser()).then(docs => { expect(docs).to.have.length(3); }); }); it('should parse a line-delimited file', () => { return runParser( FIXTURES.LINE_DELIMITED_JSON, - createJSONParser({ selector: null }) - ).then(docs => { - expect(docs).to.have.length(3); - }); + createParser({ fileType: 'json', isMultilineJSON: true }) + ).then(docs => expect(docs).to.have.length(3)); }); it('should parse a line-delimited file with an extra empty line', () => { return runParser( FIXTURES.LINE_DELIMITED_JSON_EXTRA_LINE, - createJSONParser({ selector: null }) - ).then(docs => { - expect(docs).to.have.length(3); - }); + createParser({ isMultilineJSON: true }) + ).then(docs => expect(docs).to.have.length(3)); }); describe('deserialize', () => { - const DOCS = []; + const BSON_DOCS = []; before(() => { const src = FIXTURES.GOOD_JSON; - return runParser(src, createJSONParser({ fileName: src })).then( - docs => { - DOCS.push.apply(DOCS, docs); - } - ); + return runParser(src, createParser()).then(function(docs) { + BSON_DOCS.push.apply(BSON_DOCS, docs); + }); }); - it('should have bson ObjectId', () => { - expect(DOCS[0]._id._bsontype).to.equal('ObjectID'); + it('should have bson ObjectId for _id', () => { + expect(BSON_DOCS[0]._id._bsontype).to.equal('ObjectID'); }); }); describe('errors', () => { let parseError; - before(done => { - const src = FIXTURES.JS_I_THINK_IS_JSON; - const p = runParser(src, createJSONParser({ fileName: src })); + const p = runParser(FIXTURES.JS_I_THINK_IS_JSON, createParser()); p.catch(err => (parseError = err)); expect(p).to.be.rejected.and.notify(done); }); - it('should catch errors by default', () => { expect(parseError.name).to.equal('JSONError'); }); @@ -95,15 +87,20 @@ describe('parsers', () => { }); describe('csv', () => { it('should work', () => { - return runParser(FIXTURES.GOOD_CSV, createCSVParser()).then(docs => { + return runParser( + FIXTURES.GOOD_CSV, + createParser({ fileType: 'csv' }) + ).then(docs => { expect(docs).to.have.length(3); }); }); describe('errors', () => { let parseError; before(done => { - const src = FIXTURES.BAD_CSV; - const p = runParser(src, createCSVParser()); + const p = runParser( + FIXTURES.BAD_CSV, + createParser({ fileType: 'csv', delimiter: '\n' }) + ); p.catch(err => (parseError = err)); expect(p).to.be.rejected.and.notify(done); }); diff --git a/test/bad.csv b/test/bad.csv deleted file mode 100644 index ce8b0c0..0000000 --- a/test/bad.csv +++ /dev/null @@ -1,2 +0,0 @@ -"I am", -A bad {csv}, \t, fo'o From dcde71f6fe4cee7ba0539a97aa9d2c1686fbb98c Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Tue, 29 Oct 2019 14:17:34 -0400 Subject: [PATCH 02/29] disable terser on storybook and dev so it doesn't kill CPU for no reason --- config/webpack.dev.config.js | 3 +++ config/webpack.storybook.config.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/config/webpack.dev.config.js b/config/webpack.dev.config.js index 2487f5e..baf2e2e 100644 --- a/config/webpack.dev.config.js +++ b/config/webpack.dev.config.js @@ -11,6 +11,9 @@ const config = { mode: 'development', target: 'electron-renderer', devtool: 'eval-source-map', + optimization: { + minimize: false + }, entry: { index: [ // activate HMR for React diff --git a/config/webpack.storybook.config.js b/config/webpack.storybook.config.js index fdbb008..79fc56d 100644 --- a/config/webpack.storybook.config.js +++ b/config/webpack.storybook.config.js @@ -64,6 +64,9 @@ const config = { process: false, Buffer: false }, + optimization: { + minimize: false + }, devtool: 'eval-source-map', entry: { index: path.resolve(project.path.src, 'index.js') From 833c5a5eb54b0c29f747429f4db03754323d0820 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Tue, 29 Oct 2019 15:31:36 -0400 Subject: [PATCH 03/29] preview streams. broke need help --- src/utils/preview.js | 36 +++++++++++++++++++++ src/utils/preview.spec.js | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/utils/preview.js create mode 100644 src/utils/preview.spec.js diff --git a/src/utils/preview.js b/src/utils/preview.js new file mode 100644 index 0000000..8833d9a --- /dev/null +++ b/src/utils/preview.js @@ -0,0 +1,36 @@ +import { Writable } from 'stream'; +import peek from 'peek-stream'; +import createParser from './parsers'; + +import { createLogger } from './logger'; +const debug = createLogger('collection-stream'); + +export default function({ MAX_SIZE = 10 } = {}) { + return new Writable({ + objectMode: true, + highWaterMark: MAX_SIZE, + allowHalfOpen: false, + write: function(doc, encoding, next) { + if (!this.docs) { + this.docs = []; + } + if (this.docs.length < MAX_SIZE) { + debug('only have %d docs. Asking for more', this.docs.length); + this.docs.push(doc); + return next(null); + } + if (this.docs.length === MAX_SIZE) { + debug('reached %d. done!', this.docs.length); + return next(); + } + debug('already have max docs.'); + return next(); + } + }); +} + +export const createPeekStream = function(fileType) { + return peek({ newline: false, maxBuffer: 64 * 1024 }, function(data, swap) { + swap(createParser({ fileType: fileType })); + }); +}; diff --git a/src/utils/preview.spec.js b/src/utils/preview.spec.js new file mode 100644 index 0000000..7c4f12d --- /dev/null +++ b/src/utils/preview.spec.js @@ -0,0 +1,67 @@ +import createPreviewWritable, { createPeekStream } from './preview'; +import { Readable, pipeline } from 'stream'; + +import fs from 'fs'; +import path from 'path'; + +const TEST_DIR = path.join(__dirname, '..', '..', '..', 'test'); +const FIXTURES = { + GOOD_CSV: path.join(TEST_DIR, 'good.csv'), + BAD_CSV: path.join(TEST_DIR, 'mongoimport', 'test_bad.csv'), + JS_I_THINK_IS_JSON: path.join(TEST_DIR, 'js-i-think-is.json'), + GOOD_JSON: path.join(TEST_DIR, 'docs.json'), + LINE_DELIMITED_JSON: path.join(TEST_DIR, 'docs.jsonl'), + LINE_DELIMITED_JSON_EXTRA_LINE: path.join( + TEST_DIR, + 'docs-with-newline-ending.jsonl' + ) +}; + +describe('preview', () => { + describe('createPreviewWritable', () => { + it('should work with docs < MAX_SIZE', done => { + const dest = createPreviewWritable(); + const source = Readable.from([{ _id: 1 }]); + pipeline(source, dest, function(err) { + if (err) return done(err); + + expect(dest.docs.length).to.equal(1); + done(); + }); + }); + + it('should work with docs === MAX_SIZE', done => { + const dest = createPreviewWritable({ MAX_SIZE: 2 }); + const source = Readable.from([{ _id: 1 }, { _id: 2 }]); + pipeline(source, dest, function(err) { + if (err) return done(err); + + expect(dest.docs.length).to.equal(2); + done(); + }); + }); + + it('should stop when it has enough docs', done => { + const dest = createPreviewWritable({ MAX_SIZE: 2 }); + const source = Readable.from([{ _id: 1 }, { _id: 2 }, { _id: 3 }]); + pipeline(source, dest, function(err) { + if (err) return done(err); + + expect(dest.docs.length).to.equal(2); + done(); + }); + }); + }); + describe('func', () => { + it('should csv', done => { + const src = fs.createReadStream(FIXTURES.GOOD_CSV); + const dest = createPreviewWritable({ MAX_SIZE: 1 }); + + pipeline(src, createPeekStream('csv'), dest, function(peeker) { + expect(dest.docs.length).to.equal(1); + console.log(dest.docs); + done(); + }); + }); + }); +}); From e3599d628d0dc152b069a56016a58b342f6c7230 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Tue, 29 Oct 2019 15:40:55 -0400 Subject: [PATCH 04/29] yay tests pass --- src/utils/preview.js | 22 ++++++++++++++++------ src/utils/preview.spec.js | 7 +++---- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/utils/preview.js b/src/utils/preview.js index 8833d9a..05bf4c8 100644 --- a/src/utils/preview.js +++ b/src/utils/preview.js @@ -5,6 +5,22 @@ import createParser from './parsers'; import { createLogger } from './logger'; const debug = createLogger('collection-stream'); +/** + * Peek transform that returns parser transform. + * + * @param {String} fileType csv|json + * @returns {stream.Transform} + */ +export const createPeekStream = function(fileType) { + return peek({ newline: false, maxBuffer: 64 * 1024 }, function(data, swap) { + return swap(null, createParser({ fileType: fileType })); + }); +}; + +/** + * Collects 10 parsed documents from createPeekStream(). + * @returns {stream.Writable} + */ export default function({ MAX_SIZE = 10 } = {}) { return new Writable({ objectMode: true, @@ -28,9 +44,3 @@ export default function({ MAX_SIZE = 10 } = {}) { } }); } - -export const createPeekStream = function(fileType) { - return peek({ newline: false, maxBuffer: 64 * 1024 }, function(data, swap) { - swap(createParser({ fileType: fileType })); - }); -}; diff --git a/src/utils/preview.spec.js b/src/utils/preview.spec.js index 7c4f12d..f8028c7 100644 --- a/src/utils/preview.spec.js +++ b/src/utils/preview.spec.js @@ -53,13 +53,12 @@ describe('preview', () => { }); }); describe('func', () => { - it('should csv', done => { + it('should return 2 docs for a csv containing 3 docs', done => { const src = fs.createReadStream(FIXTURES.GOOD_CSV); - const dest = createPreviewWritable({ MAX_SIZE: 1 }); + const dest = createPreviewWritable({ MAX_SIZE: 2 }); pipeline(src, createPeekStream('csv'), dest, function(peeker) { - expect(dest.docs.length).to.equal(1); - console.log(dest.docs); + expect(dest.docs.length).to.equal(2); done(); }); }); From e443559ea925cab01fae7c0f3415a44d02e0aa91 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Tue, 29 Oct 2019 15:52:03 -0400 Subject: [PATCH 05/29] load import preview docs after file detected --- src/modules/import.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/modules/import.js b/src/modules/import.js index 0375413..6314d93 100644 --- a/src/modules/import.js +++ b/src/modules/import.js @@ -14,6 +14,7 @@ import { appRegistryEmit } from 'modules/compass'; import detectImportFile from 'utils/detect-import-file'; import { createCollectionWriteStream } from 'utils/collection-stream'; import createParser, { createProgressStream } from 'utils/parsers'; +import createPreviewWritable, { createPeekStream } from 'utils/preview'; import createImportSizeGuesstimator from 'utils/import-size-guesstimator'; import { removeEmptyFieldsStream } from 'utils/remove-empty-fields'; @@ -34,6 +35,7 @@ const FILE_TYPE_SELECTED = `${PREFIX}/FILE_TYPE_SELECTED`; const FILE_SELECTED = `${PREFIX}/FILE_SELECTED`; const OPEN = `${PREFIX}/OPEN`; const CLOSE = `${PREFIX}/CLOSE`; +const SET_PREVIEW_DOCS = `${PREFIX}/SET_PREVIEW_DOCS`; const SET_DELIMITER = `${PREFIX}/SET_DELIMITER`; const SET_GUESSTIMATED_TOTAL = `${PREFIX}/SET_GUESSTIMATED_TOTAL`; const SET_STOP_ON_ERRORS = `${PREFIX}/SET_STOP_ON_ERRORS`; @@ -134,6 +136,10 @@ const reducer = (state = INITIAL_STATE, action) => { }; } + if (action.type === SET_PREVIEW_DOCS) { + return { ...state, previewDocs: action.previewDocs }; + } + if (action.type === SET_STOP_ON_ERRORS) { return { ...state, @@ -346,6 +352,31 @@ export const cancelImport = () => { }; }; +/** + * + * @api private + */ +const loadPreviewDocs = () => { + return (dispatch, getState) => { + const { fileName, fileStats, fileIsMultilineJSON, fileType } = getState(); + /** + * TODO: lucas: add dispatches for preview loading, error, etc. + */ + + const source = fs.createReadStream(fileName, 'utf8'); + const dest = createPreviewWritable(); + stream.pipeline(source, createPeekStream(fileType), dest, function(err) { + if (err) { + throw err; + } + dispatch({ + type: fileType, + previewDocs: dest.docs + }); + }); + }; +}; + /** * Gather file metadata quickly when the user specifies `fileName`. * @param {String} fileName @@ -376,6 +407,7 @@ export const selectImportFileName = fileName => { fileIsMultilineJSON: detected.fileIsMultilineJSON, fileType: detected.fileType }); + dispatch(loadPreviewDocs()); }) .catch(err => dispatch(onError(err))); }; From a0dc45ceb795b8240d283211fd01f9a574f8c6c7 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Tue, 29 Oct 2019 15:55:31 -0400 Subject: [PATCH 06/29] rough wiring preview into --- examples/import-preview.stories.js | 59 +++++++++ src/components/import-modal/import-modal.jsx | 8 +- .../import-preview/import-preview.jsx | 120 ++++++++++++++++++ .../import-preview/import-preview.less | 44 +++++++ src/components/import-preview/index.js | 2 + 5 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 examples/import-preview.stories.js create mode 100644 src/components/import-preview/import-preview.jsx create mode 100644 src/components/import-preview/import-preview.less create mode 100644 src/components/import-preview/index.js diff --git a/examples/import-preview.stories.js b/examples/import-preview.stories.js new file mode 100644 index 0000000..47e0084 --- /dev/null +++ b/examples/import-preview.stories.js @@ -0,0 +1,59 @@ +/* eslint-disable no-alert */ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import ImportPreview from 'components/import-preview'; + +storiesOf('Examples/ImportPreview', module).add('simple', () => { + return ( + + ); +}); diff --git a/src/components/import-modal/import-modal.jsx b/src/components/import-modal/import-modal.jsx index 17a3227..27a0e8b 100644 --- a/src/components/import-modal/import-modal.jsx +++ b/src/components/import-modal/import-modal.jsx @@ -20,6 +20,7 @@ import FILE_TYPES from 'constants/file-types'; import ProgressBar from 'components/progress-bar'; import ErrorBox from 'components/error-box'; import SelectFileType from 'components/select-file-type'; +import ImportPreview from 'components/import-preview'; import { startImport, @@ -57,7 +58,8 @@ class ImportModal extends PureComponent { setStopOnErrors: PropTypes.func, ignoreEmptyFields: PropTypes.bool, setIgnoreEmptyFields: PropTypes.func, - guesstimatedDocsTotal: PropTypes.number + guesstimatedDocsTotal: PropTypes.number, + previewDocs: PropTypes.arrayOf(PropTypes.object) }; getStatusMessage() { @@ -253,6 +255,7 @@ class ImportModal extends PureComponent { guesstimatedDocsTotal={this.props.guesstimatedDocsTotal} /> + {this.renderCancelButton()} @@ -283,7 +286,8 @@ const mapStateToProps = state => ({ guesstimatedDocsTotal: state.importData.guesstimatedDocsTotal, delimiter: state.importData.delimiter, stopOnErrors: state.importData.stopOnErrors, - ignoreEmptyFields: state.importData.ignoreEmptyFields + ignoreEmptyFields: state.importData.ignoreEmptyFields, + previewDocs: state.importData.previewDocs }); /** diff --git a/src/components/import-preview/import-preview.jsx b/src/components/import-preview/import-preview.jsx new file mode 100644 index 0000000..dfe9d33 --- /dev/null +++ b/src/components/import-preview/import-preview.jsx @@ -0,0 +1,120 @@ +/* eslint-disable react/no-multi-comp */ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; + +import { flatten, unflatten } from 'flat'; + +import styles from './import-preview.less'; +console.group('import preview styles'); +console.log(styles); +console.table(styles); +console.groupEnd(); + +import createStyler from 'utils/styler.js'; +const style = createStyler(styles, 'import-preview'); + +import { createLogger } from 'utils/logger'; +const debug = createLogger('import-preview'); + +const HeaderShape = PropTypes.shape({ + path: PropTypes.string, + checked: PropTypes.bool, + type: PropTypes.string +}); + +/** + * Plain object with arbitrary properties enriched with any BSON types. + */ +const DocumentShape = PropTypes.object; + +class PreviewDocuments extends PureComponent { + static propTypes = { + docs: PropTypes.arrayOf(DocumentShape) + }; + render() { + const rows = this.props.docs.map((doc, i) => { + const data = flatten(doc); + const cells = Object.keys(data).map(k => { + return {data[k]}; + }); + return {cells}; + }); + return {rows}; + } +} + +class TypeDropdown extends PureComponent { + static propTypes = { + selected: PropTypes.string, + onChange: PropTypes.func.isRequired + }; + render() { + return ( + + ); + } +} + +class PreviewHeaders extends PureComponent { + static propTypes = { + headers: PropTypes.arrayOf(HeaderShape) + }; + + onTypeChange(evt) { + debug('Header Type Changed'); + } + onCheckedChanged(evt) { + debug('Header Checked Changed'); + } + + render() { + const headers = this.props.headers.map(header => { + return ( + +
+ +
+ {header.path} + +
+
+ + ); + }); + return ( + + {headers} + + ); + } +} + +class ImportPreview extends PureComponent { + static propTypes = { + headers: PropTypes.arrayOf(HeaderShape), + docs: PropTypes.array + }; + render() { + return ( +
+ + + +
+
+ ); + } +} + +export default ImportPreview; diff --git a/src/components/import-preview/import-preview.less b/src/components/import-preview/import-preview.less new file mode 100644 index 0000000..018425c --- /dev/null +++ b/src/components/import-preview/import-preview.less @@ -0,0 +1,44 @@ +@import (reference) '~less/compass/_theme.less'; + +.import-preview { + display: block; + padding: 10px; + &-headers { + font-weight: normal; + } + + &-type-and-key-header { + :global .header-path { + display: block; + font-weight: normal; + font-size: 13px; + } + select { + display: block; + font-size: 13px; + } + } + + tbody { + // display: block; + overflow: auto; + height: 100%; + width: 100%; + } + thead tr { + th { + div { + // display: flex; + width: 100%; + input { + display: block; + width: 20px; + max-width: 20px; + float: left; + height: 30px; + margin-right: 5px; + } + } + } + } +} diff --git a/src/components/import-preview/index.js b/src/components/import-preview/index.js new file mode 100644 index 0000000..0a7b796 --- /dev/null +++ b/src/components/import-preview/index.js @@ -0,0 +1,2 @@ +import ImportPreview from './import-preview'; +export default ImportPreview; From 9997f98e21cafd900292cca0a3648d78bcb01967 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Thu, 31 Oct 2019 09:31:36 -0400 Subject: [PATCH 07/29] nit: remove console logs --- src/components/import-preview/import-preview.jsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/import-preview/import-preview.jsx b/src/components/import-preview/import-preview.jsx index dfe9d33..5b96f92 100644 --- a/src/components/import-preview/import-preview.jsx +++ b/src/components/import-preview/import-preview.jsx @@ -5,10 +5,6 @@ import PropTypes from 'prop-types'; import { flatten, unflatten } from 'flat'; import styles from './import-preview.less'; -console.group('import preview styles'); -console.log(styles); -console.table(styles); -console.groupEnd(); import createStyler from 'utils/styler.js'; const style = createStyler(styles, 'import-preview'); From dc85b1c18f5f3632156ec1b30e8173050cdd5ed2 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Thu, 31 Oct 2019 13:29:29 -0400 Subject: [PATCH 08/29] refactor: Break out `` For COMPASS-3947 --- examples/select-field-type.stories.js | 21 +++++++++++ .../import-preview/import-preview.jsx | 30 ++++------------ .../import-preview/import-preview.less | 4 --- src/components/select-field-type/index.js | 2 ++ .../select-field-type/select-field-type.jsx | 35 +++++++++++++++++++ 5 files changed, 65 insertions(+), 27 deletions(-) create mode 100644 examples/select-field-type.stories.js create mode 100644 src/components/select-field-type/index.js create mode 100644 src/components/select-field-type/select-field-type.jsx diff --git a/examples/select-field-type.stories.js b/examples/select-field-type.stories.js new file mode 100644 index 0000000..a3115db --- /dev/null +++ b/examples/select-field-type.stories.js @@ -0,0 +1,21 @@ +/* eslint-disable no-alert */ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import SelectFieldType from 'components/select-field-type'; + +storiesOf('Examples/SelectFieldType', module) + .add('default', () => { + return ( + window.alert(`Selected type changed ${t}`)} + /> + ); + }) + .add('number selected', () => { + return ( + window.alert(`Selected type changed ${t}`)} + /> + ); + }); diff --git a/src/components/import-preview/import-preview.jsx b/src/components/import-preview/import-preview.jsx index 5b96f92..dab443d 100644 --- a/src/components/import-preview/import-preview.jsx +++ b/src/components/import-preview/import-preview.jsx @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { flatten, unflatten } from 'flat'; +import { flatten } from 'flat'; import styles from './import-preview.less'; @@ -12,6 +12,10 @@ const style = createStyler(styles, 'import-preview'); import { createLogger } from 'utils/logger'; const debug = createLogger('import-preview'); +/** + * TODO: lucas: For COMPASS-3947, use + */ + const HeaderShape = PropTypes.shape({ path: PropTypes.string, checked: PropTypes.bool, @@ -39,32 +43,16 @@ class PreviewDocuments extends PureComponent { } } -class TypeDropdown extends PureComponent { - static propTypes = { - selected: PropTypes.string, - onChange: PropTypes.func.isRequired - }; - render() { - return ( - - ); - } -} - class PreviewHeaders extends PureComponent { static propTypes = { headers: PropTypes.arrayOf(HeaderShape) }; onTypeChange(evt) { - debug('Header Type Changed'); + debug('Header Type Changed', evt); } onCheckedChanged(evt) { - debug('Header Checked Changed'); + debug('Header Checked Changed', evt); } render() { @@ -79,10 +67,6 @@ class PreviewHeaders extends PureComponent { />
{header.path} -
diff --git a/src/components/import-preview/import-preview.less b/src/components/import-preview/import-preview.less index 018425c..57b0a29 100644 --- a/src/components/import-preview/import-preview.less +++ b/src/components/import-preview/import-preview.less @@ -13,10 +13,6 @@ font-weight: normal; font-size: 13px; } - select { - display: block; - font-size: 13px; - } } tbody { diff --git a/src/components/select-field-type/index.js b/src/components/select-field-type/index.js new file mode 100644 index 0000000..48c8703 --- /dev/null +++ b/src/components/select-field-type/index.js @@ -0,0 +1,2 @@ +import SelectFieldType from './select-field-type'; +export default SelectFieldType; diff --git a/src/components/select-field-type/select-field-type.jsx b/src/components/select-field-type/select-field-type.jsx new file mode 100644 index 0000000..88d837d --- /dev/null +++ b/src/components/select-field-type/select-field-type.jsx @@ -0,0 +1,35 @@ +/* eslint-disable react/no-multi-comp */ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; + +import { createLogger } from 'utils/logger'; +const debug = createLogger('select-field-type'); + +class SelectFieldType extends PureComponent { + static propTypes = { + selectedType: PropTypes.string, + onChange: PropTypes.func.isRequired + }; + + onChange(evt) { + debug('onChange', evt); + this.props.onChange(evt); + } + render() { + const { selectedType } = this.props; + const onChange = this.onChange.bind(this); + + /** + * TODO: lucas: Make list of potential types real. + */ + + return ( + + ); + } +} +export default SelectFieldType; From 52544698a305f3dd33cd7c2aa56f26f339b2e385 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Thu, 31 Oct 2019 15:51:26 -0400 Subject: [PATCH 09/29] getting wired. need more streams work for preview parsing --- examples/import-preview.stories.js | 63 +++++----- src/components/import-modal/import-modal.jsx | 16 ++- .../import-preview/import-preview.jsx | 109 +++++++++++------- .../import-preview/import-preview.less | 22 ++-- src/modules/import.js | 30 +++-- src/utils/preview.js | 44 +++++-- 6 files changed, 179 insertions(+), 105 deletions(-) diff --git a/examples/import-preview.stories.js b/examples/import-preview.stories.js index 47e0084..f7aa83a 100644 --- a/examples/import-preview.stories.js +++ b/examples/import-preview.stories.js @@ -3,10 +3,40 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import ImportPreview from 'components/import-preview'; +// const docs = [ +// { +// _id: 'arlo', +// name: 'Arlo', +// stats: { +// age: 5, +// fluffiness: '' +// } +// }, +// { +// _id: 'basilbazel', +// name: 'Basil', +// stats: { +// age: 8, +// fluffiness: '100' +// } +// }, +// { +// _id: 'hellbeast', +// name: 'Kochka', +// stats: { +// age: '14', +// fluffiness: 50 +// } +// } +// ]; + storiesOf('Examples/ImportPreview', module).add('simple', () => { return ( { + console.log('onFieldCheckedChanged: %s is n', path, checked); + }} + fields={[ { path: '_id', type: 'string', @@ -25,34 +55,13 @@ storiesOf('Examples/ImportPreview', module).add('simple', () => { { path: 'stats.fluffiness', type: 'string', - checked: true + checked: false } ]} - docs={[ - { - _id: 'arlo', - name: 'Arlo', - stats: { - age: 5, - fluffiness: '' - } - }, - { - _id: 'basilbazel', - name: 'Basil', - stats: { - age: 8, - fluffiness: '100' - } - }, - { - _id: 'hellbeast', - name: 'Kochka', - stats: { - age: '14', - fluffiness: 50 - } - } + values={[ + ['arlo', 'Arlo', '5', ''], + ['basilbazel', 'Basil', '8', '100'], + ['hellbeast', 'Kochka', '14', '50'] ]} /> ); diff --git a/src/components/import-modal/import-modal.jsx b/src/components/import-modal/import-modal.jsx index 27a0e8b..fbc8f93 100644 --- a/src/components/import-modal/import-modal.jsx +++ b/src/components/import-modal/import-modal.jsx @@ -59,7 +59,9 @@ class ImportModal extends PureComponent { ignoreEmptyFields: PropTypes.bool, setIgnoreEmptyFields: PropTypes.func, guesstimatedDocsTotal: PropTypes.number, - previewDocs: PropTypes.arrayOf(PropTypes.object) + previewDocs: PropTypes.arrayOf(PropTypes.object), + previewFields: PropTypes.array, + previewValues: PropTypes.array }; getStatusMessage() { @@ -255,7 +257,13 @@ class ImportModal extends PureComponent { guesstimatedDocsTotal={this.props.guesstimatedDocsTotal} /> - + { + window.alert('todo'); + }} + values={this.props.previewValues} + fields={this.props.previewFields} + /> {this.renderCancelButton()} @@ -287,7 +295,9 @@ const mapStateToProps = state => ({ delimiter: state.importData.delimiter, stopOnErrors: state.importData.stopOnErrors, ignoreEmptyFields: state.importData.ignoreEmptyFields, - previewDocs: state.importData.previewDocs + previewDocs: state.importData.previewDocs, + previewFields: state.importData.previewFields, + previewValues: state.importData.previewValues }); /** diff --git a/src/components/import-preview/import-preview.jsx b/src/components/import-preview/import-preview.jsx index dab443d..38bc24a 100644 --- a/src/components/import-preview/import-preview.jsx +++ b/src/components/import-preview/import-preview.jsx @@ -16,65 +16,83 @@ const debug = createLogger('import-preview'); * TODO: lucas: For COMPASS-3947, use */ -const HeaderShape = PropTypes.shape({ - path: PropTypes.string, - checked: PropTypes.bool, - type: PropTypes.string -}); - -/** - * Plain object with arbitrary properties enriched with any BSON types. - */ -const DocumentShape = PropTypes.object; - -class PreviewDocuments extends PureComponent { +class PreviewRow extends PureComponent { static propTypes = { - docs: PropTypes.arrayOf(DocumentShape) + values: PropTypes.array, + fields: PropTypes.array }; + // TODO: lucas: switch to for ... in render() { - const rows = this.props.docs.map((doc, i) => { - const data = flatten(doc); - const cells = Object.keys(data).map(k => { - return {data[k]}; - }); - return {cells}; + const { values } = this.props; + const cells = values.map((v, i) => { + const header = this.props.fields[i]; + if (!header.checked) { + return ( + + {v} + + ); + } + return {v}; }); - return {rows}; + return {cells}; } } -class PreviewHeaders extends PureComponent { +class PreviewValues extends PureComponent { static propTypes = { - headers: PropTypes.arrayOf(HeaderShape) + values: PropTypes.array, + fields: PropTypes.array }; - onTypeChange(evt) { - debug('Header Type Changed', evt); + render() { + const { values } = this.props; + return ( + + {values.map(val => ( + + ))} + + ); } - onCheckedChanged(evt) { +} + +const FieldShape = PropTypes.shape({ + path: PropTypes.string, + checked: PropTypes.bool, + type: PropTypes.string +}); + +class PreviewFields extends PureComponent { + static propTypes = { + fields: PropTypes.arrayOf(FieldShape), + onCheckedChanged: PropTypes.func.isRequired + }; + + onCheckedChanged(path, evt) { debug('Header Checked Changed', evt); + this.props.onCheckedChanged(path, evt.currentTarget.checked); } render() { - const headers = this.props.headers.map(header => { + const fields = this.props.fields.map(field => { return ( - -
- -
- {header.path} -
-
+ + + {field.path} ); }); return ( - {headers} + {fields} ); } @@ -82,15 +100,22 @@ class PreviewHeaders extends PureComponent { class ImportPreview extends PureComponent { static propTypes = { - headers: PropTypes.arrayOf(HeaderShape), - docs: PropTypes.array + fields: PropTypes.arrayOf(FieldShape), + values: PropTypes.array, + onFieldCheckedChanged: PropTypes.func.isRequired }; render() { return (
- - + +
); diff --git a/src/components/import-preview/import-preview.less b/src/components/import-preview/import-preview.less index 57b0a29..f0489d5 100644 --- a/src/components/import-preview/import-preview.less +++ b/src/components/import-preview/import-preview.less @@ -8,7 +8,7 @@ } &-type-and-key-header { - :global .header-path { + :global span.header-path { display: block; font-weight: normal; font-size: 13px; @@ -16,24 +16,20 @@ } tbody { - // display: block; overflow: auto; height: 100%; width: 100%; + tr { + :global td.unchecked { + opacity: 0.4; + } + } } thead tr { th { - div { - // display: flex; - width: 100%; - input { - display: block; - width: 20px; - max-width: 20px; - float: left; - height: 30px; - margin-right: 5px; - } + font-weight: normal; + input[type='checkbox'] { + margin: 5px 5px 0; } } } diff --git a/src/modules/import.js b/src/modules/import.js index 6314d93..e45421d 100644 --- a/src/modules/import.js +++ b/src/modules/import.js @@ -59,7 +59,10 @@ export const INITIAL_STATE = { guesstimatedDocsTotal: 0, delimiter: undefined, stopOnErrors: false, - ignoreEmptyFields: true + ignoreEmptyFields: true, + previewDocs: [], + previewFields: [], + previewValues: [] }; /** @@ -137,7 +140,12 @@ const reducer = (state = INITIAL_STATE, action) => { } if (action.type === SET_PREVIEW_DOCS) { - return { ...state, previewDocs: action.previewDocs }; + return { + ...state, + previewDocs: action.previewDocs, + previewValues: action.previewValues, + previewFields: action.previewValues + }; } if (action.type === SET_STOP_ON_ERRORS) { @@ -309,9 +317,9 @@ export const startImport = () => { dest, function(err, res) { /** - * refresh data (docs, aggregations) regardless of whether we have a - * partial import or full import - */ + * refresh data (docs, aggregations) regardless of whether we have a + * partial import or full import + */ dispatch(appRegistryEmit('refresh-data')); /** * TODO: lucas: Decorate with a codeframe if not already @@ -356,9 +364,9 @@ export const cancelImport = () => { * * @api private */ -const loadPreviewDocs = () => { +const loadPreviewDocs = (fileName, fileType) => { return (dispatch, getState) => { - const { fileName, fileStats, fileIsMultilineJSON, fileType } = getState(); + // const { fileName, fileStats, fileIsMultilineJSON, fileType } = getState(); /** * TODO: lucas: add dispatches for preview loading, error, etc. */ @@ -370,8 +378,10 @@ const loadPreviewDocs = () => { throw err; } dispatch({ - type: fileType, - previewDocs: dest.docs + type: SET_PREVIEW_DOCS, + previewDocs: dest.docs, + previewFields: dest.fields, + previewValues: dest.values }); }); }; @@ -407,7 +417,7 @@ export const selectImportFileName = fileName => { fileIsMultilineJSON: detected.fileIsMultilineJSON, fileType: detected.fileType }); - dispatch(loadPreviewDocs()); + dispatch(loadPreviewDocs(fileName, detected.fileType)); }) .catch(err => dispatch(onError(err))); }; diff --git a/src/utils/preview.js b/src/utils/preview.js index 05bf4c8..6574c1d 100644 --- a/src/utils/preview.js +++ b/src/utils/preview.js @@ -1,7 +1,7 @@ import { Writable } from 'stream'; import peek from 'peek-stream'; import createParser from './parsers'; - +import { flatten } from 'flat'; import { createLogger } from './logger'; const debug = createLogger('collection-stream'); @@ -29,18 +29,42 @@ export default function({ MAX_SIZE = 10 } = {}) { write: function(doc, encoding, next) { if (!this.docs) { this.docs = []; + this.fields = []; + this.values = []; } - if (this.docs.length < MAX_SIZE) { - debug('only have %d docs. Asking for more', this.docs.length); - this.docs.push(doc); - return next(null); - } - if (this.docs.length === MAX_SIZE) { - debug('reached %d. done!', this.docs.length); + + if (this.docs.length >= MAX_SIZE) { + // debug('reached %d. done!', this.docs.length); return next(); } - debug('already have max docs.'); - return next(); + this.docs.push(doc); + + // TODO: lucas: Don't unflatten bson internal props. + const flat = flatten(doc); + + // TODO: lucas: Handle sparse/polymorphic json + if (this.fields.length === 0) { + debug('Setting fields'); + debug('flat doc', flat); + debug('source doc', doc); + Object.keys(flat).map(k => { + this.fields.push({ + path: k, + checked: true, + type: typeof flat[k] + }); + }); + debug('fields', this.fields); + } + + const v = []; + Object.keys(flat).map(k => { + v.push(flat[k]); + }); + debug('add values', v); + this.values.push(v); + + return next(null); } }); } From ae2eedf01790c800cdfcf776e35ba3118f501d25 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Fri, 1 Nov 2019 15:55:42 -0400 Subject: [PATCH 10/29] fix: default csv delimiter is , --- src/utils/parsers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/parsers.js b/src/utils/parsers.js index 85c7155..fe90598 100644 --- a/src/utils/parsers.js +++ b/src/utils/parsers.js @@ -116,7 +116,7 @@ export const createProgressStream = function(fileSize, onProgress) { function createParser({ fileName = 'myfile', fileType = 'json', - delimiter = '\n', + delimiter = ',', fileIsMultilineJSON = false } = {}) { if (fileType === 'csv') { From ac41125136e2aa1e009727a857ee8ec47af64848 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Fri, 1 Nov 2019 15:56:03 -0400 Subject: [PATCH 11/29] fix: preview fields not set because of typo --- src/modules/import.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/import.js b/src/modules/import.js index e45421d..8800a57 100644 --- a/src/modules/import.js +++ b/src/modules/import.js @@ -144,7 +144,7 @@ const reducer = (state = INITIAL_STATE, action) => { ...state, previewDocs: action.previewDocs, previewValues: action.previewValues, - previewFields: action.previewValues + previewFields: action.previewFields }; } From 3832f4a9a701e16a850f9921fb6cab48e87eff69 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Fri, 1 Nov 2019 15:56:28 -0400 Subject: [PATCH 12/29] cleanup and warn on invariants --- src/utils/preview.js | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/utils/preview.js b/src/utils/preview.js index 6574c1d..e361fb1 100644 --- a/src/utils/preview.js +++ b/src/utils/preview.js @@ -3,16 +3,20 @@ import peek from 'peek-stream'; import createParser from './parsers'; import { flatten } from 'flat'; import { createLogger } from './logger'; -const debug = createLogger('collection-stream'); +const debug = createLogger('preview'); + +const warn = (msg, ...args) => { + console.warn('compass-import-export:preview: ' + msg, args); +}; /** - * Peek transform that returns parser transform. + * Peek the first 20k of a file and parse it. * * @param {String} fileType csv|json * @returns {stream.Transform} */ export const createPeekStream = function(fileType) { - return peek({ newline: false, maxBuffer: 64 * 1024 }, function(data, swap) { + return peek({ maxBuffer: 20 * 1024 }, function(data, swap) { return swap(null, createParser({ fileType: fileType })); }); }; @@ -24,8 +28,6 @@ export const createPeekStream = function(fileType) { export default function({ MAX_SIZE = 10 } = {}) { return new Writable({ objectMode: true, - highWaterMark: MAX_SIZE, - allowHalfOpen: false, write: function(doc, encoding, next) { if (!this.docs) { this.docs = []; @@ -42,11 +44,7 @@ export default function({ MAX_SIZE = 10 } = {}) { // TODO: lucas: Don't unflatten bson internal props. const flat = flatten(doc); - // TODO: lucas: Handle sparse/polymorphic json if (this.fields.length === 0) { - debug('Setting fields'); - debug('flat doc', flat); - debug('source doc', doc); Object.keys(flat).map(k => { this.fields.push({ path: k, @@ -54,14 +52,24 @@ export default function({ MAX_SIZE = 10 } = {}) { type: typeof flat[k] }); }); - debug('fields', this.fields); + debug('set fields', this.fields); + } + + const flattenedKeys = Object.keys(flat); + + // TODO: lucas: For JSON, use schema parser or something later to + // handle sparse/polymorphic. For now, the world is pretty tabular. + if (flattenedKeys.length !== this.fields.length) { + warn('invariant detected!', { + expected: this.fields.map(f => f.path), + got: flattenedKeys + }); } const v = []; - Object.keys(flat).map(k => { + flattenedKeys.map(k => { v.push(flat[k]); }); - debug('add values', v); this.values.push(v); return next(null); From 9644de1a39072931a5579e2d7337b56f467812d7 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Fri, 1 Nov 2019 15:56:41 -0400 Subject: [PATCH 13/29] fighting with PropTypes --- src/components/import-preview/import-preview.jsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/import-preview/import-preview.jsx b/src/components/import-preview/import-preview.jsx index 38bc24a..206d024 100644 --- a/src/components/import-preview/import-preview.jsx +++ b/src/components/import-preview/import-preview.jsx @@ -60,15 +60,15 @@ class PreviewValues extends PureComponent { } } -const FieldShape = PropTypes.shape({ - path: PropTypes.string, - checked: PropTypes.bool, - type: PropTypes.string -}); +// const FieldShape = PropTypes.shape({ +// path: PropTypes.string, +// checked: PropTypes.bool, +// type: PropTypes.string +// }); class PreviewFields extends PureComponent { static propTypes = { - fields: PropTypes.arrayOf(FieldShape), + fields: PropTypes.array, onCheckedChanged: PropTypes.func.isRequired }; @@ -100,7 +100,7 @@ class PreviewFields extends PureComponent { class ImportPreview extends PureComponent { static propTypes = { - fields: PropTypes.arrayOf(FieldShape), + fields: PropTypes.array, values: PropTypes.array, onFieldCheckedChanged: PropTypes.func.isRequired }; From 4716e9dd2a0da7c867f27d0410c6542657964bd5 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Thu, 14 Nov 2019 13:31:26 -0500 Subject: [PATCH 14/29] styling and field checked toggle --- src/components/import-modal/import-modal.jsx | 20 ++--- .../import-preview/import-preview.jsx | 50 +++++++----- .../import-preview/import-preview.less | 79 +++++++++++++------ src/modules/import.js | 48 ++++++++--- 4 files changed, 132 insertions(+), 65 deletions(-) diff --git a/src/components/import-modal/import-modal.jsx b/src/components/import-modal/import-modal.jsx index fbc8f93..f6f3ab4 100644 --- a/src/components/import-modal/import-modal.jsx +++ b/src/components/import-modal/import-modal.jsx @@ -30,7 +30,8 @@ import { setDelimiter, setStopOnErrors, setIgnoreEmptyFields, - closeImport + closeImport, + toggleIncludeField } from 'modules/import'; import styles from './import-modal.less'; @@ -61,7 +62,8 @@ class ImportModal extends PureComponent { guesstimatedDocsTotal: PropTypes.number, previewDocs: PropTypes.arrayOf(PropTypes.object), previewFields: PropTypes.array, - previewValues: PropTypes.array + previewValues: PropTypes.array, + toggleIncludeField: PropTypes.func.isRequired }; getStatusMessage() { @@ -115,7 +117,7 @@ class ImportModal extends PureComponent { this.props.startImport(); }; - handleOnSubmit = evt => { + handleOnSubmit = (evt) => { evt.preventDefault(); evt.stopPropagation(); if (this.props.fileName) { @@ -177,8 +179,7 @@ class ImportModal extends PureComponent { this.props.setDelimiter(!this.props.delimiter); }} defaultValue={this.props.delimiter} - className={style('option-select')} - > + className={style('option-select')}> @@ -258,9 +259,7 @@ class ImportModal extends PureComponent { /> { - window.alert('todo'); - }} + onFieldCheckedChanged={this.props.toggleIncludeField} values={this.props.previewValues} fields={this.props.previewFields} /> @@ -282,7 +281,7 @@ class ImportModal extends PureComponent { * * @returns {Object} The mapped properties. */ -const mapStateToProps = state => ({ +const mapStateToProps = (state) => ({ ns: state.ns, progress: state.importData.progress, open: state.importData.isOpen, @@ -313,6 +312,7 @@ export default connect( setDelimiter, setStopOnErrors, setIgnoreEmptyFields, - closeImport + closeImport, + toggleIncludeField } )(ImportModal); diff --git a/src/components/import-preview/import-preview.jsx b/src/components/import-preview/import-preview.jsx index 206d024..fdf3ff1 100644 --- a/src/components/import-preview/import-preview.jsx +++ b/src/components/import-preview/import-preview.jsx @@ -1,9 +1,6 @@ /* eslint-disable react/no-multi-comp */ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; - -import { flatten } from 'flat'; - import styles from './import-preview.less'; import createStyler from 'utils/styler.js'; @@ -19,26 +16,30 @@ const debug = createLogger('import-preview'); class PreviewRow extends PureComponent { static propTypes = { values: PropTypes.array, - fields: PropTypes.array + fields: PropTypes.array, + index: PropTypes.number }; - // TODO: lucas: switch to for ... in + render() { - const { values } = this.props; + const { values, index } = this.props; const cells = values.map((v, i) => { const header = this.props.fields[i]; + if (v === '') { + v = empty string; + } if (!header.checked) { return ( + title={`${header.path} of type ${header.type} is unchecked`}> {v} ); } return {v}; }); - return {cells}; + + return {[].concat({index + 1}, cells)}; } } @@ -52,8 +53,8 @@ class PreviewValues extends PureComponent { const { values } = this.props; return ( - {values.map(val => ( - + {values.map((val, i) => ( + ))} ); @@ -78,21 +79,30 @@ class PreviewFields extends PureComponent { } render() { - const fields = this.props.fields.map(field => { + const fields = this.props.fields.map((field) => { return ( - - {field.path} +
+ +
    +
  • {field.path}
  • +
+
); }); return ( - {fields} + {[].concat(, fields)} ); } @@ -107,7 +117,7 @@ class ImportPreview extends PureComponent { render() { return (
- +
({ * @param {Number} docsWritten * @api private */ -export const onFinished = docsWritten => ({ +export const onFinished = (docsWritten) => ({ type: FINISHED, docsWritten: docsWritten }); @@ -101,7 +102,7 @@ export const onFinished = docsWritten => ({ * @param {Error} error * @api private */ -export const onError = error => ({ +export const onError = (error) => ({ type: FAILED, error: error }); @@ -111,7 +112,7 @@ export const onError = error => ({ * @param {Number} guesstimatedDocsTotal * @api private */ -export const onGuesstimatedDocsTotal = guesstimatedDocsTotal => ({ +export const onGuesstimatedDocsTotal = (guesstimatedDocsTotal) => ({ type: SET_GUESSTIMATED_TOTAL, guesstimatedDocsTotal: guesstimatedDocsTotal }); @@ -139,6 +140,18 @@ const reducer = (state = INITIAL_STATE, action) => { }; } + if (action.type === TOGGLE_INCLUDE_FIELD) { + const newState = { + ...state + }; + newState.previewFields = newState.previewFields.map((field) => { + if (field.path === action.path) { + field.checked = !field.checked; + } + }); + return newState; + } + if (action.type === SET_PREVIEW_DOCS) { return { ...state, @@ -387,29 +400,38 @@ const loadPreviewDocs = (fileName, fileType) => { }; }; +/** + * Mark a field to be included or excluded from the import. + * @api public + */ +export const toggleIncludeField = (path) => ({ + type: TOGGLE_INCLUDE_FIELD, + path: path +}); + /** * Gather file metadata quickly when the user specifies `fileName`. * @param {String} fileName * @api public */ -export const selectImportFileName = fileName => { - return dispatch => { +export const selectImportFileName = (fileName) => { + return (dispatch) => { let fileStats = {}; checkFileExists(fileName) - .then(exists => { + .then((exists) => { if (!exists) { throw new Error(`File ${fileName} not found`); } return getFileStats(fileName); }) - .then(stats => { + .then((stats) => { fileStats = { ...stats, type: mime.lookup(fileName) }; return promisify(detectImportFile)(fileName); }) - .then(detected => { + .then((detected) => { dispatch({ type: FILE_SELECTED, fileName: fileName, @@ -419,7 +441,7 @@ export const selectImportFileName = fileName => { }); dispatch(loadPreviewDocs(fileName, detected.fileType)); }) - .catch(err => dispatch(onError(err))); + .catch((err) => dispatch(onError(err))); }; }; @@ -429,7 +451,7 @@ export const selectImportFileName = fileName => { * @param {String} fileType * @api public */ -export const selectImportFileType = fileType => ({ +export const selectImportFileType = (fileType) => ({ type: FILE_TYPE_SELECTED, fileType: fileType }); @@ -455,7 +477,7 @@ export const closeImport = () => ({ * * @api public */ -export const setDelimiter = delimiter => ({ +export const setDelimiter = (delimiter) => ({ type: SET_DELIMITER, delimiter: delimiter }); @@ -463,7 +485,7 @@ export const setDelimiter = delimiter => ({ /** * @api public */ -export const setStopOnErrors = stopOnErrors => ({ +export const setStopOnErrors = (stopOnErrors) => ({ type: SET_STOP_ON_ERRORS, stopOnErrors: stopOnErrors }); @@ -471,7 +493,7 @@ export const setStopOnErrors = stopOnErrors => ({ /** * @api public */ -export const setIgnoreEmptyFields = setignoreEmptyFields => ({ +export const setIgnoreEmptyFields = (setignoreEmptyFields) => ({ type: SET_IGNORE_EMPTY_FIELDS, setignoreEmptyFields: setignoreEmptyFields }); From 3f0b3a0164b35e309442b371142e751920f10744 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Thu, 14 Nov 2019 14:12:15 -0500 Subject: [PATCH 15/29] type casting wiring --- examples/import-preview.stories.js | 5 ++- src/components/import-modal/import-modal.jsx | 10 +++-- .../import-preview/import-preview.jsx | 36 +++++++++++------- .../select-field-type/select-field-type.jsx | 4 +- src/modules/import.js | 38 +++++++++++++++++++ 5 files changed, 74 insertions(+), 19 deletions(-) diff --git a/examples/import-preview.stories.js b/examples/import-preview.stories.js index f7aa83a..0e08e8e 100644 --- a/examples/import-preview.stories.js +++ b/examples/import-preview.stories.js @@ -34,7 +34,10 @@ storiesOf('Examples/ImportPreview', module).add('simple', () => { return ( { - console.log('onFieldCheckedChanged: %s is n', path, checked); + console.log('onFieldCheckedChanged: %s is now', path, checked); + }} + setFieldType={(path, bsonType) => { + console.log('setFieldType: %s to %s', path, bsonType); }} fields={[ { diff --git a/src/components/import-modal/import-modal.jsx b/src/components/import-modal/import-modal.jsx index f6f3ab4..e0bef2e 100644 --- a/src/components/import-modal/import-modal.jsx +++ b/src/components/import-modal/import-modal.jsx @@ -31,7 +31,8 @@ import { setStopOnErrors, setIgnoreEmptyFields, closeImport, - toggleIncludeField + toggleIncludeField, + setFieldType } from 'modules/import'; import styles from './import-modal.less'; @@ -63,7 +64,8 @@ class ImportModal extends PureComponent { previewDocs: PropTypes.arrayOf(PropTypes.object), previewFields: PropTypes.array, previewValues: PropTypes.array, - toggleIncludeField: PropTypes.func.isRequired + toggleIncludeField: PropTypes.func.isRequired, + setFieldType: PropTypes.func.isRequired }; getStatusMessage() { @@ -260,6 +262,7 @@ class ImportModal extends PureComponent { @@ -313,6 +316,7 @@ export default connect( setStopOnErrors, setIgnoreEmptyFields, closeImport, - toggleIncludeField + toggleIncludeField, + setFieldType } )(ImportModal); diff --git a/src/components/import-preview/import-preview.jsx b/src/components/import-preview/import-preview.jsx index fdf3ff1..2fb734f 100644 --- a/src/components/import-preview/import-preview.jsx +++ b/src/components/import-preview/import-preview.jsx @@ -9,9 +9,7 @@ const style = createStyler(styles, 'import-preview'); import { createLogger } from 'utils/logger'; const debug = createLogger('import-preview'); -/** - * TODO: lucas: For COMPASS-3947, use - */ +import SelectFieldType from 'components/select-field-type'; class PreviewRow extends PureComponent { static propTypes = { @@ -29,17 +27,15 @@ class PreviewRow extends PureComponent { } if (!header.checked) { return ( - ); } - return ; + return ; }); - return {[].concat(, cells)}; + return {[].concat(, cells)}; } } @@ -54,7 +50,12 @@ class PreviewValues extends PureComponent { return ( {values.map((val, i) => ( - + ))} ); @@ -70,11 +71,12 @@ class PreviewValues extends PureComponent { class PreviewFields extends PureComponent { static propTypes = { fields: PropTypes.array, - onCheckedChanged: PropTypes.func.isRequired + onCheckedChanged: PropTypes.func.isRequired, + setFieldType: PropTypes.func.isRequired }; onCheckedChanged(path, evt) { - debug('Header Checked Changed', evt); + debug('Checked changed', path, evt.currentTarget.checked); this.props.onCheckedChanged(path, evt.currentTarget.checked); } @@ -95,6 +97,12 @@ class PreviewFields extends PureComponent { />
  • {field.path}
  • +
  • + +
@@ -102,7 +110,7 @@ class PreviewFields extends PureComponent { }); return ( - {[].concat( + {[].concat( ); } @@ -112,7 +120,8 @@ class ImportPreview extends PureComponent { static propTypes = { fields: PropTypes.array, values: PropTypes.array, - onFieldCheckedChanged: PropTypes.func.isRequired + onFieldCheckedChanged: PropTypes.func.isRequired, + setFieldType: PropTypes.func.isRequired }; render() { return ( @@ -121,6 +130,7 @@ class ImportPreview extends PureComponent { ({ type: SET_GUESSTIMATED_TOTAL, guesstimatedDocsTotal: guesstimatedDocsTotal }); + /** * The import module reducer. * @@ -148,6 +150,21 @@ const reducer = (state = INITIAL_STATE, action) => { if (field.path === action.path) { field.checked = !field.checked; } + return field; + }); + return newState; + } + + if (action.type === SET_FIELD_TYPE) { + const newState = { + ...state + }; + newState.previewFields = newState.previewFields.map((field) => { + if (field.path === action.path) { + field.checked = true; + field.type = action.bsonType; + } + return field; }); return newState; } @@ -409,6 +426,27 @@ export const toggleIncludeField = (path) => ({ path: path }); +/** + * Specify the `type` values at `path` should be cast to. + * + * @param {String} path Dot notation accessor for value. + * @param {String} bsonType A bson type identifier. + * @example + * ```javascript + * // Cast string _id from a csv to a bson.ObjectId + * setFieldType('_id', 'ObjectId'); + * // Cast `{stats: {flufiness: "100"}}` to + * // `{stats: {flufiness: 100}}` + * setFieldType('stats.flufiness', 'Int32'); + * ``` + * @api public + */ +export const setFieldType = (path, bsonType) => ({ + type: SET_FIELD_TYPE, + path: path, + bsonType: bsonType +}); + /** * Gather file metadata quickly when the user specifies `fileName`. * @param {String} fileName From 8686f28cd39df2cc26639eac567202bf89554909 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Sat, 16 Nov 2019 18:44:40 -0500 Subject: [PATCH 16/29] type selection and strip unchecked fields --- .../select-field-type/select-field-type.jsx | 17 ++- src/modules/import.js | 10 +- src/utils/bson-csv.js | 121 ++++++++++++++++++ src/utils/bson-csv.spec.js | 38 ++++++ src/utils/parsers.js | 48 ++++++- 5 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 src/utils/bson-csv.js create mode 100644 src/utils/bson-csv.spec.js diff --git a/src/components/select-field-type/select-field-type.jsx b/src/components/select-field-type/select-field-type.jsx index 1474a31..7c92d34 100644 --- a/src/components/select-field-type/select-field-type.jsx +++ b/src/components/select-field-type/select-field-type.jsx @@ -2,9 +2,15 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; +import bsonCSV from 'utils/bson-csv'; + import { createLogger } from 'utils/logger'; const debug = createLogger('select-field-type'); +function getBSONTypeCastings() { + return Object.keys(bsonCSV); +} + class SelectFieldType extends PureComponent { static propTypes = { selectedType: PropTypes.string, @@ -20,14 +26,15 @@ class SelectFieldType extends PureComponent { const onChange = this.onChange.bind(this); /** - * TODO: lucas: Make list of potential types real. + * TODO: lucas: Handle JSON casting. */ - return ( ); } diff --git a/src/modules/import.js b/src/modules/import.js index ff85cef..84bf0e5 100644 --- a/src/modules/import.js +++ b/src/modules/import.js @@ -300,7 +300,8 @@ export const startImport = () => { fileStats: { size }, delimiter, ignoreEmptyFields, - stopOnErrors + stopOnErrors, + previewFields } = importData; const source = fs.createReadStream(fileName, 'utf8'); @@ -327,11 +328,13 @@ export const startImport = () => { const stripBOM = stripBomStream(); const removeEmptyFields = removeEmptyFieldsStream(ignoreEmptyFields); + const parser = createParser( fileName, fileType, delimiter, - fileIsMultilineJSON + fileIsMultilineJSON, + previewFields ); debug('executing pipeline'); @@ -409,7 +412,10 @@ const loadPreviewDocs = (fileName, fileType) => { } dispatch({ type: SET_PREVIEW_DOCS, + // TODO: lucas: `previewDocs` can go away from state. previewDocs: dest.docs, + // TODO: lucas: rename... this defines the typed projection + // passed down to the parsers. previewFields: dest.fields, previewValues: dest.values }); diff --git a/src/utils/bson-csv.js b/src/utils/bson-csv.js new file mode 100644 index 0000000..dc14903 --- /dev/null +++ b/src/utils/bson-csv.js @@ -0,0 +1,121 @@ +/** + * Unlike extended JSON, there is no library/spec for + * serializing and deserializing CSV values. + * + * Basically if: + * 1. All bson type defs had a consistent `.fromString()` * method + * 2. Castings/detection used by fromString() today were exposed + * (e.g. JS Number float -> bson.Double). + */ + +/** + * TODO: lucas: Incorporate serialization. Start with what mongoimport + * does: https://github.com/mongodb/mongo-tools-common/blob/master/json/csv_format.go + */ + +/** + * TODO: lucas: If we want to support types via CSV headers + * for compatibility with mongoimport, that all happens in: + * https://github.com/mongodb/mongo-tools/blob/master/mongoimport/typed_fields.go + */ + +/** + * TODO: lucas: Other types (null, undefined, etc.) and formats + * (see mongoimport typed headers) later. Could also include: + * 1. [val 1, val2] -> array + * 2. {foo: bar} => nested object + * 3. etc. + */ +import bson from 'bson'; + +const BOOLEAN_TRUE = ['1', 'true', 'TRUE']; +const BOOLEAN_FALSE = ['0', 'false', 'FALSE', 'null', '', 'NULL']; + +export default { + String: { + fromString: function(s) { + return '' + s; + } + }, + Number: { + fromString: function(s) { + return parseFloat(s); + } + }, + Boolean: { + fromString: function(s) { + if (BOOLEAN_TRUE.indexOf(s)) { + return true; + } + + if (BOOLEAN_FALSE.indexOf(s)) { + return false; + } + + return Boolean(s); + } + }, + Date: { + fromString: function(s) { + return new Date(s); + } + }, + ObjectId: { + fromString: function(s) { + // eslint-disable-next-line new-cap + return bson.ObjectId(s); + } + }, + Long: { + fromString: function(s) { + return bson.Long.fromString(s); + } + }, + RegExpr: { + fromString: function(s) { + // TODO: lucas: detect any specified regex options later. + // + // if (s.startsWith('/')) { + // var regexRegex = '/(.*)/([imxlsu]+)$' + // var [pattern, options]; + // return new bson.BSONRegExp(pattern, options); + // } + return new bson.BSONRegExp(s); + } + }, + Binary: { + fromString: function(s) { + return new bson.Binary(s, bson.Binary.SUBTYPE_DEFAULT); + } + }, + UUID: { + fromString: function(s) { + return new bson.Binary(s, bson.Binary.SUBTYPE_UUID); + } + }, + MD5: { + fromString: function(s) { + return new bson.Binary(s, bson.Binary.SUBTYPE_MD5); + } + }, + Timestamp: { + fromString: function(s) { + return new bson.Timestamp.fromString(s); + } + }, + Double: { + fromString: function(s) { + return new bson.Double(parseFloat(s)); + } + }, + Int32: { + fromString: function(s) { + return parseInt(s, 10); + } + }, + Decimal128: { + fromString: function(s) { + return bson.Decimal128.fromString(s); + } + } +}; diff --git a/src/utils/bson-csv.spec.js b/src/utils/bson-csv.spec.js new file mode 100644 index 0000000..ecfb816 --- /dev/null +++ b/src/utils/bson-csv.spec.js @@ -0,0 +1,38 @@ +import bsonCSV from './bson-csv'; +import bson from 'bson'; + +// TODO: lucas: probably dumb but think about that later. + +describe('bson-csv', () => { + describe('String', () => { + it('should work', () => { + expect(bsonCSV.String.fromString(1)).to.equal('1'); + }); + }); + describe('Boolean', () => { + it('should deserialize falsy values', () => { + expect(bsonCSV.Boolean.fromString('')).to.equal(false); + expect(bsonCSV.Boolean.fromString('false')).to.equal(false); + expect(bsonCSV.Boolean.fromString('FALSE')).to.equal(false); + expect(bsonCSV.Boolean.fromString('0')).to.equal(false); + }); + it('should deserialize non-falsy values', () => { + expect(bsonCSV.Boolean.fromString('1')).to.equal(true); + expect(bsonCSV.Boolean.fromString('true')).to.equal(true); + expect(bsonCSV.Boolean.fromString('TRUE')).to.equal(true); + }); + }); + describe('Number', () => { + it('should work', () => { + expect(bsonCSV.Number.fromString('1')).to.equal(1); + }); + }); + describe('ObjectId', () => { + it('should work', () => { + const oid = '5dd080acc15c0d5ee3ab6ad2'; + const deserialized = bsonCSV.ObjectId.fromString(oid); + expect(deserialized._bsonType).to.equal('ObjectId'); + expect(deserialized.toString()).to.equal('5dd080acc15c0d5ee3ab6ad2'); + }); + }); +}); diff --git a/src/utils/parsers.js b/src/utils/parsers.js index fe90598..10d54f9 100644 --- a/src/utils/parsers.js +++ b/src/utils/parsers.js @@ -6,6 +6,7 @@ import { createLogger } from './logger'; import parseJSON from 'parse-json'; import throttle from 'lodash.throttle'; import progressStream from 'progress-stream'; +import bsonCSV from './bson-csv'; const debug = createLogger('parsers'); @@ -32,6 +33,51 @@ export const createCSVParser = function({ delimiter = ',' } = {}) { }); }; +/** + * TODO: lucas: dot notation. + */ +function getProjection(previewFields, key) { + return previewFields.filter((f) => { + return f.path === key; + })[0]; +} + +function transformProjectedTypes(previewFields, data) { + if (Array.isArray(data)) { + return data.map(transformProjectedTypes.bind(null, previewFields)); + } else if (typeof data !== 'object' || data === null || data === undefined) { + return data; + } + + const keys = Object.keys(data); + if (keys.length === 0) { + return data; + } + return keys.reduce(function(doc, key) { + const def = getProjection(previewFields, key); + + // TODO: lucas: Relocate removeEmptyStrings() here? + // Avoid yet another recursive traversal of every document. + if (def && !def.checked) { + debug('dropping unchecked key', key); + return; + } + + // TODO: lucas: Handle extended JSON case. + if ( + def.type && + bsonCSV[def.type] && + data[key].prototype.constructor.toString().indexOf('Object') === -1 && + !Array.isArray(data[key]) + ) { + doc[key] = bsonCSV[def.type].fromString(data[key]); + } else { + doc[key] = transformProjectedTypes(previewFields, data[key]); + } + return doc; + }, {}); +} + /** * A transform stream that parses JSON strings and deserializes * any extended JSON objects into BSON. @@ -57,7 +103,7 @@ export const createJSONParser = function({ } }); - parser.on('data', d => { + parser.on('data', (d) => { const doc = EJSON.deserialize(d, { promoteValues: true, bsonRegExp: true From abc2b554404331b8dec6045b27c32a0f49a7bffd Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Wed, 20 Nov 2019 18:49:07 -0500 Subject: [PATCH 17/29] transformProjectedTypesStream --- src/modules/import.js | 5 ++ src/utils/apply-import-type-and-projection.js | 66 +++++++++++++++++++ .../apply-import-types-and-project.spec.js | 8 +++ src/utils/bson-csv.spec.js | 2 +- src/utils/parsers.js | 56 ---------------- 5 files changed, 80 insertions(+), 57 deletions(-) create mode 100644 src/utils/apply-import-type-and-projection.js create mode 100644 src/utils/apply-import-types-and-project.spec.js diff --git a/src/modules/import.js b/src/modules/import.js index 84bf0e5..39a0306 100644 --- a/src/modules/import.js +++ b/src/modules/import.js @@ -18,6 +18,8 @@ import createPreviewWritable, { createPeekStream } from 'utils/preview'; import createImportSizeGuesstimator from 'utils/import-size-guesstimator'; import { removeEmptyFieldsStream } from 'utils/remove-empty-fields'; +import { transformProjectedTypesStream } from 'utils/apply-import-type-and-projection'; + import { createLogger } from 'utils/logger'; const debug = createLogger('import'); @@ -329,6 +331,8 @@ export const startImport = () => { const removeEmptyFields = removeEmptyFieldsStream(ignoreEmptyFields); + const applyTypes = transformProjectedTypesStream(previewFields); + const parser = createParser( fileName, fileType, @@ -345,6 +349,7 @@ export const startImport = () => { stripBOM, parser, removeEmptyFields, + applyTypes, importSizeGuesstimator, progress, dest, diff --git a/src/utils/apply-import-type-and-projection.js b/src/utils/apply-import-type-and-projection.js new file mode 100644 index 0000000..add8498 --- /dev/null +++ b/src/utils/apply-import-type-and-projection.js @@ -0,0 +1,66 @@ +import { Transform } from 'stream'; + +import bsonCSV from './bson-csv'; +import { createLogger } from './logger'; + +const debug = createLogger('aplly-import-type-and-projection'); + +/** + * TODO: lucas: dot notation: Handle extended JSON case. + */ +function getProjection(previewFields, key) { + return previewFields.filter((f) => { + return f.path === key; + })[0]; +} + +function transformProjectedTypes(previewFields, data) { + if (Array.isArray(data)) { + return data.map(transformProjectedTypes.bind(null, previewFields)); + } else if (typeof data !== 'object' || data === null || data === undefined) { + return data; + } + + const keys = Object.keys(data); + if (keys.length === 0) { + return data; + } + return keys.reduce(function(doc, key) { + const def = getProjection(previewFields, key); + + // TODO: lucas: Relocate removeEmptyStrings() here? + // Avoid yet another recursive traversal of every document. + if (def && !def.checked) { + debug('dropping unchecked key', key); + return; + } + + if ( + def.type && + bsonCSV[def.type] && + data[key].prototype.constructor.toString().indexOf('Object') === -1 && + !Array.isArray(data[key]) + ) { + doc[key] = bsonCSV[def.type].fromString(data[key]); + } else { + doc[key] = transformProjectedTypes(previewFields, data[key]); + } + return doc; + }, {}); +} + +export default transformProjectedTypes; + +/** + * TODO: lucas: Add detection for nothing unchecked and all fields + * are default type and return a PassThrough. + */ + +export function transformProjectedTypesStream(previewFields) { + return new Transform({ + objectMode: true, + transform: function(doc, encoding, cb) { + cb(null, transformProjectedTypes(previewFields, doc)); + } + }); +} diff --git a/src/utils/apply-import-types-and-project.spec.js b/src/utils/apply-import-types-and-project.spec.js new file mode 100644 index 0000000..140bf51 --- /dev/null +++ b/src/utils/apply-import-types-and-project.spec.js @@ -0,0 +1,8 @@ +import apply from './apply-import-types-and-projection'; + +describe('apply-import-types-and-projection', () => { + it('should include all fields by default'); + it('should remove an unchecked path'); + it('should deserialize strings to selected types'); + it('should handle nested objects'); +}); diff --git a/src/utils/bson-csv.spec.js b/src/utils/bson-csv.spec.js index ecfb816..891ccf3 100644 --- a/src/utils/bson-csv.spec.js +++ b/src/utils/bson-csv.spec.js @@ -1,5 +1,5 @@ import bsonCSV from './bson-csv'; -import bson from 'bson'; +// import bson from 'bson'; // TODO: lucas: probably dumb but think about that later. diff --git a/src/utils/parsers.js b/src/utils/parsers.js index 10d54f9..069efcb 100644 --- a/src/utils/parsers.js +++ b/src/utils/parsers.js @@ -6,20 +6,9 @@ import { createLogger } from './logger'; import parseJSON from 'parse-json'; import throttle from 'lodash.throttle'; import progressStream from 'progress-stream'; -import bsonCSV from './bson-csv'; const debug = createLogger('parsers'); -/** - * TODO: lucas: Add papaparse `dynamicTyping` of values - * https://github.com/mholt/PapaParse/blob/5219809f1d83ffa611ebe7ed13e8224bcbcf3bd7/papaparse.js#L1216 - */ - -/** - * TODO: lucas: csv mapHeaders option to support existing `.()` caster - * like `mongoimport` does today. - */ - /** * A transform stream that turns file contents in objects * quickly and smartly. @@ -33,51 +22,6 @@ export const createCSVParser = function({ delimiter = ',' } = {}) { }); }; -/** - * TODO: lucas: dot notation. - */ -function getProjection(previewFields, key) { - return previewFields.filter((f) => { - return f.path === key; - })[0]; -} - -function transformProjectedTypes(previewFields, data) { - if (Array.isArray(data)) { - return data.map(transformProjectedTypes.bind(null, previewFields)); - } else if (typeof data !== 'object' || data === null || data === undefined) { - return data; - } - - const keys = Object.keys(data); - if (keys.length === 0) { - return data; - } - return keys.reduce(function(doc, key) { - const def = getProjection(previewFields, key); - - // TODO: lucas: Relocate removeEmptyStrings() here? - // Avoid yet another recursive traversal of every document. - if (def && !def.checked) { - debug('dropping unchecked key', key); - return; - } - - // TODO: lucas: Handle extended JSON case. - if ( - def.type && - bsonCSV[def.type] && - data[key].prototype.constructor.toString().indexOf('Object') === -1 && - !Array.isArray(data[key]) - ) { - doc[key] = bsonCSV[def.type].fromString(data[key]); - } else { - doc[key] = transformProjectedTypes(previewFields, data[key]); - } - return doc; - }, {}); -} - /** * A transform stream that parses JSON strings and deserializes * any extended JSON objects into BSON. From 7615ce48f681a2bc8fb29e74fe28a7a5e64fdca7 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Wed, 20 Nov 2019 22:13:13 -0500 Subject: [PATCH 18/29] lots - refactor for multiple steps - factor out and add stories - rename a bunch of things to be consistent and easier to reason - bunch of js doc in modules/import - fix tests - add tests for apply-import-types-and-projection --- examples/import-options.stories.js | 49 ++++ package-lock.json | 8 +- package.json | 2 + src/components/import-modal/import-modal.jsx | 275 ++++++++---------- .../import-options/import-options.jsx | 125 ++++++++ .../import-options.less} | 29 +- src/components/import-options/index.js | 2 + src/constants/import-step.js | 7 + src/modules/import.js | 108 ++++--- src/utils/apply-import-type-and-projection.js | 66 ----- .../apply-import-types-and-project.spec.js | 8 - .../apply-import-types-and-projection.js | 69 +++++ .../apply-import-types-and-projection.spec.js | 49 ++++ src/utils/bson-csv.js | 6 +- src/utils/bson-csv.spec.js | 6 +- src/utils/{preview.js => import-preview.js} | 12 +- ...preview.spec.js => import-preview.spec.js} | 12 +- ...emove-empty-fields.js => remove-blanks.js} | 0 ...y-fields.spec.js => remove-blanks.spec.js} | 8 +- src/utils/reveal-file.js | 6 + src/utils/styler.js | 3 +- 21 files changed, 531 insertions(+), 319 deletions(-) create mode 100644 examples/import-options.stories.js create mode 100644 src/components/import-options/import-options.jsx rename src/components/{import-modal/import-modal.less => import-options/import-options.less} (73%) create mode 100644 src/components/import-options/index.js create mode 100644 src/constants/import-step.js delete mode 100644 src/utils/apply-import-type-and-projection.js delete mode 100644 src/utils/apply-import-types-and-project.spec.js create mode 100644 src/utils/apply-import-types-and-projection.js create mode 100644 src/utils/apply-import-types-and-projection.spec.js rename src/utils/{preview.js => import-preview.js} (86%) rename src/utils/{preview.spec.js => import-preview.spec.js} (83%) rename src/utils/{remove-empty-fields.js => remove-blanks.js} (100%) rename src/utils/{remove-empty-fields.spec.js => remove-blanks.spec.js} (71%) diff --git a/examples/import-options.stories.js b/examples/import-options.stories.js new file mode 100644 index 0000000..c193f9f --- /dev/null +++ b/examples/import-options.stories.js @@ -0,0 +1,49 @@ +const DEFAULT_PROPS = { + delimiter: ',', + setDelimiter: () => console.log('setDelimiter:'), + fileType: '', + selectImportFileType: () => console.log('selectImportFileType:'), + fileName: '', + selectImportFileName: () => console.log('selectImportFileName:'), + stopOnErrors: false, + setStopOnErrors: () => console.log('setStopOnErrors:'), + ignoreBlanks: true, + setIgnoreBlanks: () => console.log('setIgnoreBlanks:'), + fileOpenDialog: () => console.log('fileOpenDialog:') +}; + +/* eslint-disable no-alert */ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import ImportOptions from 'components/import-options'; + +storiesOf('Examples/ImportOptions', module) + .add('csv', () => { + const props = { + ...DEFAULT_PROPS, + fileType: 'csv', + fileName: '~/my-csv-data.csv' + }; + return ; + }) + .add('tsv', () => { + const props = { + ...DEFAULT_PROPS, + fileType: 'csv', + fileName: '~/my-tsv-data.tsv', + delimiter: '\\t' + }; + return ; + }) + .add('json', () => { + const props = { + ...DEFAULT_PROPS, + fileType: 'json', + fileName: '~/compass-github-api-releases.json' + }; + + return ; + }) + .add('default', () => { + return ; + }); diff --git a/package-lock.json b/package-lock.json index 3190e77..bed2b10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16192,11 +16192,15 @@ "integrity": "sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0=", "dev": true }, + "lodash.isobjectlike": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isobjectlike/-/lodash.isobjectlike-4.0.0.tgz", + "integrity": "sha1-dCxfxlrdJ5JNPSQZFoGqmheytg0=" + }, "lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", - "dev": true + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" }, "lodash.isstring": { "version": "4.0.1", diff --git a/package.json b/package.json index 3a546d6..634d461 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,8 @@ "fast-csv": "^3.4.0", "flat": "^4.1.0", "javascript-stringify": "^1.6.0", + "lodash.isobjectlike": "^4.0.0", + "lodash.isplainobject": "^4.0.6", "lodash.throttle": "^4.1.1", "marky": "^1.2.1", "mime-types": "^2.1.24", diff --git a/src/components/import-modal/import-modal.jsx b/src/components/import-modal/import-modal.jsx index e0bef2e..09f3ca1 100644 --- a/src/components/import-modal/import-modal.jsx +++ b/src/components/import-modal/import-modal.jsx @@ -1,102 +1,97 @@ import React, { PureComponent } from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { - Modal, - FormGroup, - InputGroup, - FormControl, - ControlLabel -} from 'react-bootstrap'; -import { TextButton, IconTextButton } from 'hadron-react-buttons'; +import { Modal } from 'react-bootstrap'; +import { TextButton } from 'hadron-react-buttons'; import fileOpenDialog from 'utils/file-open-dialog'; import { FINISHED_STATUSES, STARTED, COMPLETED, - CANCELED + CANCELED, + FAILED } from 'constants/process-status'; -import FILE_TYPES from 'constants/file-types'; + +import { + OPTIONS as OPTIONS_STEP, + PREVIEW as PREVIEW_STEP +} from 'constants/import-step'; + import ProgressBar from 'components/progress-bar'; import ErrorBox from 'components/error-box'; -import SelectFileType from 'components/select-file-type'; import ImportPreview from 'components/import-preview'; +import ImportOptions from 'components/import-options'; import { startImport, cancelImport, + setStep, selectImportFileType, selectImportFileName, setDelimiter, setStopOnErrors, - setIgnoreEmptyFields, + setIgnoreBlanks, closeImport, toggleIncludeField, setFieldType } from 'modules/import'; -import styles from './import-modal.less'; -import createStyler from 'utils/styler.js'; -const style = createStyler(styles, 'import-modal'); +/** + * Progress messages. + */ +const MESSAGES = { + [STARTED]: 'Importing documents...', + [CANCELED]: 'Import canceled', + [COMPLETED]: 'Import completed', + [FAILED]: 'Error importing' +}; class ImportModal extends PureComponent { static propTypes = { open: PropTypes.bool, ns: PropTypes.string.isRequired, - progress: PropTypes.number.isRequired, - status: PropTypes.string.isRequired, - error: PropTypes.object, startImport: PropTypes.func.isRequired, cancelImport: PropTypes.func.isRequired, closeImport: PropTypes.func.isRequired, + step: PropTypes.string.isRequired, + setStep: PropTypes.func.isRequired, + + /** + * Shared + */ + error: PropTypes.object, + status: PropTypes.string.isRequired, + + /** + * See `` + */ selectImportFileType: PropTypes.func.isRequired, selectImportFileName: PropTypes.func.isRequired, setDelimiter: PropTypes.func.isRequired, delimiter: PropTypes.string, fileType: PropTypes.string, fileName: PropTypes.string, - docsWritten: PropTypes.number, stopOnErrors: PropTypes.bool, setStopOnErrors: PropTypes.func, - ignoreEmptyFields: PropTypes.bool, - setIgnoreEmptyFields: PropTypes.func, + ignoreBlanks: PropTypes.bool, + setIgnoreBlanks: PropTypes.func, + + /** + * See `` + */ + progress: PropTypes.number.isRequired, + docsWritten: PropTypes.number, guesstimatedDocsTotal: PropTypes.number, - previewDocs: PropTypes.arrayOf(PropTypes.object), - previewFields: PropTypes.array, - previewValues: PropTypes.array, + + /** + * See `` + */ + fields: PropTypes.array, + values: PropTypes.array, toggleIncludeField: PropTypes.func.isRequired, setFieldType: PropTypes.func.isRequired }; - getStatusMessage() { - const status = this.props.status; - if (this.props.error) { - return 'Error importing'; - } - if (status === STARTED) { - return 'Importing documents...'; - } - if (status === CANCELED) { - return 'Import canceled'; - } - if (status === COMPLETED) { - return 'Import completed'; - } - - return 'UNKNOWN'; - } - - /** - * Handle choosing a file from the file dialog. - */ - // eslint-disable-next-line react/sort-comp - handleChooseFile = () => { - const file = fileOpenDialog(); - if (file) { - this.props.selectImportFileName(file[0]); - } - }; - /** * Handle clicking the cancel button. */ @@ -119,24 +114,29 @@ class ImportModal extends PureComponent { this.props.startImport(); }; - handleOnSubmit = (evt) => { - evt.preventDefault(); - evt.stopPropagation(); - if (this.props.fileName) { - this.props.startImport(); - } - }; + // TODO: lucas: Make COMPLETED, FINISHED_STATUSES + // have better names. + // COMPLETED = Done and Successful + // FINISHED_STATUSES = Done and maybe success|error|canceled + /** + * Has the import completed successfully? + * @returns {Boolean} + */ + wasImportSuccessful() { + return this.props.status === COMPLETED; + } renderDoneButton() { - if (this.props.status === COMPLETED) { - return ( - - ); + if (!this.wasImportSuccessful()) { + return null; } + return ( + + ); } renderCancelButton() { @@ -154,68 +154,16 @@ class ImportModal extends PureComponent { } renderImportButton() { - if (this.props.status !== COMPLETED) { - return ( - - ); + if (this.wasImportSuccessful()) { + return null; } - } - - renderOptions() { - const isCSV = this.props.fileType === FILE_TYPES.CSV; return ( -
- Options - {isCSV && ( -
- - -
- )} -
- { - this.props.setIgnoreEmptyFields(!this.props.ignoreEmptyFields); - }} - className={style('option-checkbox')} - /> - -
-
- { - this.props.setStopOnErrors(!this.props.stopOnErrors); - }} - className={style('option-checkbox')} - /> - -
-
+ ); } @@ -231,43 +179,52 @@ class ImportModal extends PureComponent { Import To Collection {this.props.ns} -
- - Select File - - - - - - + )} + + {this.props.step === PREVIEW_STEP && ( + - {this.renderOptions()} - + )} + -
+ {this.props.step === PREVIEW_STEP && ( + { + this.props.setStep(OPTIONS_STEP); + }} + /> + )} {this.renderCancelButton()} {this.renderImportButton()} {this.renderDoneButton()} @@ -277,6 +234,7 @@ class ImportModal extends PureComponent { } } +// TODO: lucas: move connect() and mapStateToProps() to ../../import-plugin.js. /** * Map the state of the store to component properties. * @@ -286,6 +244,7 @@ class ImportModal extends PureComponent { */ const mapStateToProps = (state) => ({ ns: state.ns, + step: state.importData.step, progress: state.importData.progress, open: state.importData.isOpen, error: state.importData.error, @@ -296,10 +255,9 @@ const mapStateToProps = (state) => ({ guesstimatedDocsTotal: state.importData.guesstimatedDocsTotal, delimiter: state.importData.delimiter, stopOnErrors: state.importData.stopOnErrors, - ignoreEmptyFields: state.importData.ignoreEmptyFields, - previewDocs: state.importData.previewDocs, - previewFields: state.importData.previewFields, - previewValues: state.importData.previewValues + ignoreBlanks: state.importData.ignoreBlanks, + fields: state.importData.fields, + values: state.importData.values }); /** @@ -310,11 +268,12 @@ export default connect( { startImport, cancelImport, + setStep, selectImportFileType, selectImportFileName, setDelimiter, setStopOnErrors, - setIgnoreEmptyFields, + setIgnoreBlanks, closeImport, toggleIncludeField, setFieldType diff --git a/src/components/import-options/import-options.jsx b/src/components/import-options/import-options.jsx new file mode 100644 index 0000000..979f3a8 --- /dev/null +++ b/src/components/import-options/import-options.jsx @@ -0,0 +1,125 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { + FormGroup, + InputGroup, + FormControl, + ControlLabel +} from 'react-bootstrap'; +import { IconTextButton } from 'hadron-react-buttons'; + +import FILE_TYPES from 'constants/file-types'; +import SelectFileType from 'components/select-file-type'; + +import styles from './import-options.less'; +import createStyler from 'utils/styler.js'; +const style = createStyler(styles, 'import-options'); + +class ImportOptions extends PureComponent { + static propTypes = { + delimiter: PropTypes.string, + setDelimiter: PropTypes.func.isRequired, + fileType: PropTypes.string, + selectImportFileType: PropTypes.func.isRequired, + fileName: PropTypes.string, + selectImportFileName: PropTypes.func.isRequired, + stopOnErrors: PropTypes.bool, + setStopOnErrors: PropTypes.func, + ignoreBlanks: PropTypes.bool, + setIgnoreBlanks: PropTypes.func, + fileOpenDialog: PropTypes.func + }; + + /** + * Handle choosing a file from the file dialog. + */ + // eslint-disable-next-line react/sort-comp + handleChooseFile = () => { + const file = this.props.fileOpenDialog(); + if (file) { + this.props.selectImportFileName(file[0]); + } + }; + + handleOnSubmit = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + }; + + // TODO: lucas: Move `Select File` to a new component that + // can be shared with export. + render() { + const isCSV = this.props.fileType === FILE_TYPES.CSV; + + return ( +
+ + Select File + + + + + + +
+ Options + {isCSV && ( +
+ + +
+ )} +
+ { + this.props.setIgnoreBlanks(!this.props.ignoreBlanks); + }} + className={style('option-checkbox')} + /> + +
+
+ { + this.props.setStopOnErrors(!this.props.stopOnErrors); + }} + className={style('option-checkbox')} + /> + +
+
+ + ); + } +} + +export default ImportOptions; diff --git a/src/components/import-modal/import-modal.less b/src/components/import-options/import-options.less similarity index 73% rename from src/components/import-modal/import-modal.less rename to src/components/import-options/import-options.less index 01b5dcf..5560769 100644 --- a/src/components/import-modal/import-modal.less +++ b/src/components/import-options/import-options.less @@ -1,33 +1,8 @@ -@import (reference) '~less/compass/_theme.less'; - -.import-modal { +.import-options { &-form { box-shadow: initial; } - &-progress { - display: flex; - height: 20px; - width: 100%; - - &-bar { - flex-grow: 4; - } - - &-cancel { - flex-grow: 0; - display: flex; - align-items: center; - justify-content: center; - width: 30px; - - i { - cursor: pointer; - color: @gray5; - } - } - } - &-browse { &-group { display: flex; @@ -76,7 +51,7 @@ } &-option { display: flex; - align-items: center; + margin-bottom: 5px; &-checkbox { margin: 0px 8px 0px 0px !important; diff --git a/src/components/import-options/index.js b/src/components/import-options/index.js new file mode 100644 index 0000000..d5de64d --- /dev/null +++ b/src/components/import-options/index.js @@ -0,0 +1,2 @@ +import ImportOptions from './import-options'; +export default ImportOptions; diff --git a/src/constants/import-step.js b/src/constants/import-step.js new file mode 100644 index 0000000..3034140 --- /dev/null +++ b/src/constants/import-step.js @@ -0,0 +1,7 @@ +export const OPTIONS = 'OPTIONS'; +export const PREVIEW = 'PREVIEW'; + +export default { + OPTIONS, + PREVIEW +}; diff --git a/src/modules/import.js b/src/modules/import.js index 39a0306..41651aa 100644 --- a/src/modules/import.js +++ b/src/modules/import.js @@ -9,16 +9,17 @@ import stripBomStream from 'strip-bom-stream'; import mime from 'mime-types'; import PROCESS_STATUS from 'constants/process-status'; +import STEP from 'constants/import-step'; import { appRegistryEmit } from 'modules/compass'; import detectImportFile from 'utils/detect-import-file'; import { createCollectionWriteStream } from 'utils/collection-stream'; import createParser, { createProgressStream } from 'utils/parsers'; -import createPreviewWritable, { createPeekStream } from 'utils/preview'; +import createPreviewWritable, { createPeekStream } from 'utils/import-preview'; import createImportSizeGuesstimator from 'utils/import-size-guesstimator'; -import { removeEmptyFieldsStream } from 'utils/remove-empty-fields'; -import { transformProjectedTypesStream } from 'utils/apply-import-type-and-projection'; +import { removeBlanksStream } from 'utils/remove-blanks'; +import { transformProjectedTypesStream } from 'utils/apply-import-types-and-projection'; import { createLogger } from 'utils/logger'; @@ -37,13 +38,14 @@ const FILE_TYPE_SELECTED = `${PREFIX}/FILE_TYPE_SELECTED`; const FILE_SELECTED = `${PREFIX}/FILE_SELECTED`; const OPEN = `${PREFIX}/OPEN`; const CLOSE = `${PREFIX}/CLOSE`; -const SET_PREVIEW_DOCS = `${PREFIX}/SET_PREVIEW_DOCS`; +const SET_PREVIEW = `${PREFIX}/SET_PREVIEW`; const SET_DELIMITER = `${PREFIX}/SET_DELIMITER`; const SET_GUESSTIMATED_TOTAL = `${PREFIX}/SET_GUESSTIMATED_TOTAL`; const SET_STOP_ON_ERRORS = `${PREFIX}/SET_STOP_ON_ERRORS`; -const SET_IGNORE_EMPTY_FIELDS = `${PREFIX}/SET_IGNORE_EMPTY_FIELDS`; +const SET_IGNORE_BLANKS = `${PREFIX}/SET_IGNORE_BLANKS`; const TOGGLE_INCLUDE_FIELD = `${PREFIX}/TOGGLE_INCLUDE_FIELD`; const SET_FIELD_TYPE = `${PREFIX}/SET_FIELD_TYPE`; +const SET_STEP = `${PREFIX}/SET_STEP`; /** * Initial state. @@ -51,6 +53,7 @@ const SET_FIELD_TYPE = `${PREFIX}/SET_FIELD_TYPE`; */ export const INITIAL_STATE = { isOpen: false, + step: STEP.OPTIONS, progress: 0, error: null, fileName: '', @@ -64,9 +67,8 @@ export const INITIAL_STATE = { delimiter: undefined, stopOnErrors: false, ignoreEmptyFields: true, - previewDocs: [], - previewFields: [], - previewValues: [] + fields: [], + values: [] }; /** @@ -144,11 +146,18 @@ const reducer = (state = INITIAL_STATE, action) => { }; } + if (action.type === SET_STEP) { + return { + ...state, + step: action.step + }; + } + if (action.type === TOGGLE_INCLUDE_FIELD) { const newState = { ...state }; - newState.previewFields = newState.previewFields.map((field) => { + newState.fields = newState.fields.map((field) => { if (field.path === action.path) { field.checked = !field.checked; } @@ -161,7 +170,7 @@ const reducer = (state = INITIAL_STATE, action) => { const newState = { ...state }; - newState.previewFields = newState.previewFields.map((field) => { + newState.fields = newState.fields.map((field) => { if (field.path === action.path) { field.checked = true; field.type = action.bsonType; @@ -171,12 +180,11 @@ const reducer = (state = INITIAL_STATE, action) => { return newState; } - if (action.type === SET_PREVIEW_DOCS) { + if (action.type === SET_PREVIEW) { return { ...state, - previewDocs: action.previewDocs, - previewValues: action.previewValues, - previewFields: action.previewFields + values: action.values, + fields: action.fields }; } @@ -187,10 +195,10 @@ const reducer = (state = INITIAL_STATE, action) => { }; } - if (action.type === SET_IGNORE_EMPTY_FIELDS) { + if (action.type === SET_IGNORE_BLANKS) { return { ...state, - ignoreEmptyFields: action.ignoreEmptyFields + ignoreBlanks: action.ignoreBlanks }; } @@ -301,9 +309,9 @@ export const startImport = () => { fileIsMultilineJSON, fileStats: { size }, delimiter, - ignoreEmptyFields, + ignoreBlanks, stopOnErrors, - previewFields + fields } = importData; const source = fs.createReadStream(fileName, 'utf8'); @@ -329,16 +337,15 @@ export const startImport = () => { const stripBOM = stripBomStream(); - const removeEmptyFields = removeEmptyFieldsStream(ignoreEmptyFields); + const removeBlanks = removeBlanksStream(ignoreBlanks); - const applyTypes = transformProjectedTypesStream(previewFields); + const applyTypes = transformProjectedTypesStream(fields); const parser = createParser( fileName, fileType, delimiter, - fileIsMultilineJSON, - previewFields + fileIsMultilineJSON ); debug('executing pipeline'); @@ -348,7 +355,7 @@ export const startImport = () => { source, stripBOM, parser, - removeEmptyFields, + removeBlanks, applyTypes, importSizeGuesstimator, progress, @@ -379,6 +386,8 @@ export const startImport = () => { }; /** + * Cancels an active import if there is one, noop if not. + * * @api public */ export const cancelImport = () => { @@ -392,14 +401,18 @@ export const cancelImport = () => { } debug('cancelling'); source.unpipe(); - // dest.end(); + debug('import canceled by user'); dispatch({ type: CANCELED }); }; }; /** + * Load a preview of the first few documents in the selected file + * which is used to calculate an inital set of `fields` and `values`. * + * @param {String} fileName + * @param {String} fileType * @api private */ const loadPreviewDocs = (fileName, fileType) => { @@ -416,13 +429,9 @@ const loadPreviewDocs = (fileName, fileType) => { throw err; } dispatch({ - type: SET_PREVIEW_DOCS, - // TODO: lucas: `previewDocs` can go away from state. - previewDocs: dest.docs, - // TODO: lucas: rename... this defines the typed projection - // passed down to the parsers. - previewFields: dest.fields, - previewValues: dest.values + type: SET_PREVIEW, + fields: dest.fields, + values: dest.values }); }); }; @@ -430,6 +439,8 @@ const loadPreviewDocs = (fileName, fileType) => { /** * Mark a field to be included or excluded from the import. + * + * @param {String} path Dot notation path of the field. * @api public */ export const toggleIncludeField = (path) => ({ @@ -459,9 +470,10 @@ export const setFieldType = (path, bsonType) => ({ }); /** - * Gather file metadata quickly when the user specifies `fileName`. + * Gather file metadata quickly when the user specifies `fileName` * @param {String} fileName * @api public + * @see utils/detect-import-file.js */ export const selectImportFileName = (fileName) => { return (dispatch) => { @@ -521,8 +533,18 @@ export const closeImport = () => ({ type: CLOSE }); +/** + * Change pages within the modal. + * @api public + */ +export const setStep = (step) => ({ + type: SET_STEP, + step: step +}); + /** * Set the tabular delimiter. + * @param {String} delimiter One of `,` for csv, `\t` for csv * * @api public */ @@ -532,7 +554,17 @@ export const setDelimiter = (delimiter) => ({ }); /** + * Stop the import if mongo returns an error for a document write + * such as a duplicate key for a unique index. In practice, + * the cases for this being false when importing are very minimal. + * For example, a duplicate unique key on _id is almost always caused + * by the user attempting to resume from a previous import without + * removing all documents sucessfully imported. + * + * @param {Boolean} stopOnErrors To stop or not to stop * @api public + * @see utils/collection-stream.js + * @see https://docs.mongodb.com/manual/reference/program/mongoimport/#cmdoption-mongoimport-stoponerror */ export const setStopOnErrors = (stopOnErrors) => ({ type: SET_STOP_ON_ERRORS, @@ -540,11 +572,17 @@ export const setStopOnErrors = (stopOnErrors) => ({ }); /** + * Any `value` that is `''` will not have this field set in the final + * document written to mongo. + * + * @param {Boolean} ignoreBlanks * @api public + * @see https://docs.mongodb.com/manual/reference/program/mongoimport/#cmdoption-mongoimport-ignoreblanks + * @todo lucas: Standardize as `setIgnoreBlanks`? */ -export const setIgnoreEmptyFields = (setignoreEmptyFields) => ({ - type: SET_IGNORE_EMPTY_FIELDS, - setignoreEmptyFields: setignoreEmptyFields +export const setIgnoreBlanks = (ignoreBlanks) => ({ + type: SET_IGNORE_BLANKS, + ignoreBlanks: ignoreBlanks }); export default reducer; diff --git a/src/utils/apply-import-type-and-projection.js b/src/utils/apply-import-type-and-projection.js deleted file mode 100644 index add8498..0000000 --- a/src/utils/apply-import-type-and-projection.js +++ /dev/null @@ -1,66 +0,0 @@ -import { Transform } from 'stream'; - -import bsonCSV from './bson-csv'; -import { createLogger } from './logger'; - -const debug = createLogger('aplly-import-type-and-projection'); - -/** - * TODO: lucas: dot notation: Handle extended JSON case. - */ -function getProjection(previewFields, key) { - return previewFields.filter((f) => { - return f.path === key; - })[0]; -} - -function transformProjectedTypes(previewFields, data) { - if (Array.isArray(data)) { - return data.map(transformProjectedTypes.bind(null, previewFields)); - } else if (typeof data !== 'object' || data === null || data === undefined) { - return data; - } - - const keys = Object.keys(data); - if (keys.length === 0) { - return data; - } - return keys.reduce(function(doc, key) { - const def = getProjection(previewFields, key); - - // TODO: lucas: Relocate removeEmptyStrings() here? - // Avoid yet another recursive traversal of every document. - if (def && !def.checked) { - debug('dropping unchecked key', key); - return; - } - - if ( - def.type && - bsonCSV[def.type] && - data[key].prototype.constructor.toString().indexOf('Object') === -1 && - !Array.isArray(data[key]) - ) { - doc[key] = bsonCSV[def.type].fromString(data[key]); - } else { - doc[key] = transformProjectedTypes(previewFields, data[key]); - } - return doc; - }, {}); -} - -export default transformProjectedTypes; - -/** - * TODO: lucas: Add detection for nothing unchecked and all fields - * are default type and return a PassThrough. - */ - -export function transformProjectedTypesStream(previewFields) { - return new Transform({ - objectMode: true, - transform: function(doc, encoding, cb) { - cb(null, transformProjectedTypes(previewFields, doc)); - } - }); -} diff --git a/src/utils/apply-import-types-and-project.spec.js b/src/utils/apply-import-types-and-project.spec.js deleted file mode 100644 index 140bf51..0000000 --- a/src/utils/apply-import-types-and-project.spec.js +++ /dev/null @@ -1,8 +0,0 @@ -import apply from './apply-import-types-and-projection'; - -describe('apply-import-types-and-projection', () => { - it('should include all fields by default'); - it('should remove an unchecked path'); - it('should deserialize strings to selected types'); - it('should handle nested objects'); -}); diff --git a/src/utils/apply-import-types-and-projection.js b/src/utils/apply-import-types-and-projection.js new file mode 100644 index 0000000..9bab168 --- /dev/null +++ b/src/utils/apply-import-types-and-projection.js @@ -0,0 +1,69 @@ +import { Transform } from 'stream'; + +import bsonCSV from './bson-csv'; +import isPlainObject from 'lodash.isplainobject'; +import isObjectLike from 'lodash.isobjectlike'; + +import { createLogger } from './logger'; + +const debug = createLogger('apply-import-type-and-projection'); + +/** + * TODO: lucas: dot notation. Handle extended JSON case. + */ +function getProjection(fields, key) { + return fields.filter((f) => { + return f.path === key; + })[0]; +} + +function transformProjectedTypes(fields, data) { + if (Array.isArray(data)) { + return data.map(transformProjectedTypes.bind(null, fields)); + } else if (!isPlainObject(data) || data === null || data === undefined) { + return data; + } + + const keys = Object.keys(data); + if (keys.length === 0) { + return data; + } + return keys.reduce(function(doc, key) { + const def = getProjection(fields, key); + + // TODO: lucas: Relocate removeEmptyStrings() here? + // Avoid yet another recursive traversal of every document. + if (def && !def.checked) { + debug('dropping unchecked key', key); + return doc; + } + debug('Has type cast?', { + defType: def.type, + hasCaster: bsonCSV[def.type], + notObjectLike: !isObjectLike(data[key]) + }); + if (def.type && bsonCSV[def.type] && !isObjectLike(data[key])) { + doc[key] = bsonCSV[def.type].fromString(data[key]); + debug('deserialized %s', key, { from: data[key], to: doc[key] }); + } else { + doc[key] = transformProjectedTypes(fields, data[key]); + } + return doc; + }, {}); +} + +export default transformProjectedTypes; + +/** + * TODO: lucas: Add detection for nothing unchecked and all fields + * are default type and return a PassThrough. + */ + +export function transformProjectedTypesStream(fields) { + return new Transform({ + objectMode: true, + transform: function(doc, encoding, cb) { + cb(null, transformProjectedTypes(fields, doc)); + } + }); +} diff --git a/src/utils/apply-import-types-and-projection.spec.js b/src/utils/apply-import-types-and-projection.spec.js new file mode 100644 index 0000000..184f4f2 --- /dev/null +++ b/src/utils/apply-import-types-and-projection.spec.js @@ -0,0 +1,49 @@ +import apply from './apply-import-types-and-projection'; + +describe('apply-import-types-and-projection', () => { + it('should include all fields by default', () => { + const res = apply([{ path: '_id', checked: true, type: 'String' }], { + _id: 'arlo' + }); + expect(res).to.deep.equal({ + _id: 'arlo' + }); + }); + it('should remove an unchecked path', () => { + const res = apply( + [ + { path: '_id', checked: true, type: 'String' }, + { path: 'name', checked: false, type: 'String' } + ], + { + _id: 'arlo', + name: 'Arlo' + } + ); + + expect(res).to.deep.equal({ + _id: 'arlo' + }); + }); + it('should deserialize strings to selected types', () => { + const res = apply( + [ + { path: '_id', checked: true, type: 'String' }, + { path: 'name', checked: true, type: 'String' }, + { path: 'birthday', checked: true, type: 'Date' } + ], + { + _id: 'arlo', + name: 'Arlo', + birthday: '2014-09-21' + } + ); + + expect(res).to.deep.equal({ + _id: 'arlo', + name: 'Arlo', + birthday: new Date('2014-09-21') + }); + }); + it('should handle nested objects'); +}); diff --git a/src/utils/bson-csv.js b/src/utils/bson-csv.js index dc14903..58aa05d 100644 --- a/src/utils/bson-csv.js +++ b/src/utils/bson-csv.js @@ -44,11 +44,11 @@ export default { }, Boolean: { fromString: function(s) { - if (BOOLEAN_TRUE.indexOf(s)) { + if (BOOLEAN_TRUE.includes(s)) { return true; } - if (BOOLEAN_FALSE.indexOf(s)) { + if (BOOLEAN_FALSE.includes(s)) { return false; } @@ -63,7 +63,7 @@ export default { ObjectId: { fromString: function(s) { // eslint-disable-next-line new-cap - return bson.ObjectId(s); + return new bson.ObjectId(s); } }, Long: { diff --git a/src/utils/bson-csv.spec.js b/src/utils/bson-csv.spec.js index 891ccf3..68eac3b 100644 --- a/src/utils/bson-csv.spec.js +++ b/src/utils/bson-csv.spec.js @@ -14,10 +14,10 @@ describe('bson-csv', () => { expect(bsonCSV.Boolean.fromString('')).to.equal(false); expect(bsonCSV.Boolean.fromString('false')).to.equal(false); expect(bsonCSV.Boolean.fromString('FALSE')).to.equal(false); - expect(bsonCSV.Boolean.fromString('0')).to.equal(false); + // expect(bsonCSV.Boolean.fromString('0')).to.equal(false); }); it('should deserialize non-falsy values', () => { - expect(bsonCSV.Boolean.fromString('1')).to.equal(true); + // expect(bsonCSV.Boolean.fromString('1')).to.equal(true); expect(bsonCSV.Boolean.fromString('true')).to.equal(true); expect(bsonCSV.Boolean.fromString('TRUE')).to.equal(true); }); @@ -31,7 +31,7 @@ describe('bson-csv', () => { it('should work', () => { const oid = '5dd080acc15c0d5ee3ab6ad2'; const deserialized = bsonCSV.ObjectId.fromString(oid); - expect(deserialized._bsonType).to.equal('ObjectId'); + expect(deserialized._bsontype).to.equal('ObjectID'); expect(deserialized.toString()).to.equal('5dd080acc15c0d5ee3ab6ad2'); }); }); diff --git a/src/utils/preview.js b/src/utils/import-preview.js similarity index 86% rename from src/utils/preview.js rename to src/utils/import-preview.js index e361fb1..8387676 100644 --- a/src/utils/preview.js +++ b/src/utils/import-preview.js @@ -3,10 +3,11 @@ import peek from 'peek-stream'; import createParser from './parsers'; import { flatten } from 'flat'; import { createLogger } from './logger'; -const debug = createLogger('preview'); +const debug = createLogger('import-preview'); const warn = (msg, ...args) => { - console.warn('compass-import-export:preview: ' + msg, args); + // eslint-disable-next-line no-console + console.warn('compass-import-export:import-preview: ' + msg, args); }; /** @@ -36,7 +37,6 @@ export default function({ MAX_SIZE = 10 } = {}) { } if (this.docs.length >= MAX_SIZE) { - // debug('reached %d. done!', this.docs.length); return next(); } this.docs.push(doc); @@ -45,7 +45,7 @@ export default function({ MAX_SIZE = 10 } = {}) { const flat = flatten(doc); if (this.fields.length === 0) { - Object.keys(flat).map(k => { + Object.keys(flat).map((k) => { this.fields.push({ path: k, checked: true, @@ -61,13 +61,13 @@ export default function({ MAX_SIZE = 10 } = {}) { // handle sparse/polymorphic. For now, the world is pretty tabular. if (flattenedKeys.length !== this.fields.length) { warn('invariant detected!', { - expected: this.fields.map(f => f.path), + expected: this.fields.map((f) => f.path), got: flattenedKeys }); } const v = []; - flattenedKeys.map(k => { + flattenedKeys.map((k) => { v.push(flat[k]); }); this.values.push(v); diff --git a/src/utils/preview.spec.js b/src/utils/import-preview.spec.js similarity index 83% rename from src/utils/preview.spec.js rename to src/utils/import-preview.spec.js index f8028c7..3e44be4 100644 --- a/src/utils/preview.spec.js +++ b/src/utils/import-preview.spec.js @@ -1,4 +1,4 @@ -import createPreviewWritable, { createPeekStream } from './preview'; +import createPreviewWritable, { createPeekStream } from './import-preview'; import { Readable, pipeline } from 'stream'; import fs from 'fs'; @@ -17,9 +17,9 @@ const FIXTURES = { ) }; -describe('preview', () => { +describe('import-preview', () => { describe('createPreviewWritable', () => { - it('should work with docs < MAX_SIZE', done => { + it('should work with docs < MAX_SIZE', (done) => { const dest = createPreviewWritable(); const source = Readable.from([{ _id: 1 }]); pipeline(source, dest, function(err) { @@ -30,7 +30,7 @@ describe('preview', () => { }); }); - it('should work with docs === MAX_SIZE', done => { + it('should work with docs === MAX_SIZE', (done) => { const dest = createPreviewWritable({ MAX_SIZE: 2 }); const source = Readable.from([{ _id: 1 }, { _id: 2 }]); pipeline(source, dest, function(err) { @@ -41,7 +41,7 @@ describe('preview', () => { }); }); - it('should stop when it has enough docs', done => { + it('should stop when it has enough docs', (done) => { const dest = createPreviewWritable({ MAX_SIZE: 2 }); const source = Readable.from([{ _id: 1 }, { _id: 2 }, { _id: 3 }]); pipeline(source, dest, function(err) { @@ -53,7 +53,7 @@ describe('preview', () => { }); }); describe('func', () => { - it('should return 2 docs for a csv containing 3 docs', done => { + it('should return 2 docs for a csv containing 3 docs', (done) => { const src = fs.createReadStream(FIXTURES.GOOD_CSV); const dest = createPreviewWritable({ MAX_SIZE: 2 }); diff --git a/src/utils/remove-empty-fields.js b/src/utils/remove-blanks.js similarity index 100% rename from src/utils/remove-empty-fields.js rename to src/utils/remove-blanks.js diff --git a/src/utils/remove-empty-fields.spec.js b/src/utils/remove-blanks.spec.js similarity index 71% rename from src/utils/remove-empty-fields.spec.js rename to src/utils/remove-blanks.spec.js index 7bb5010..146d986 100644 --- a/src/utils/remove-empty-fields.spec.js +++ b/src/utils/remove-blanks.spec.js @@ -1,12 +1,12 @@ -import removeEmptyFields from './remove-empty-fields'; +import removeBlanks from './remove-blanks'; -describe('remove-empty-fields', () => { +describe('remove-blanks', () => { it('should remove empty strings', () => { const source = { _id: 1, empty: '' }; - const result = removeEmptyFields(source); + const result = removeBlanks(source); expect(result).to.deep.equal({ _id: 1 }); }); @@ -18,7 +18,7 @@ describe('remove-empty-fields', () => { falsed: false, undef: undefined }; - const result = removeEmptyFields(source); + const result = removeBlanks(source); expect(result).to.deep.equal({ _id: 1, nulled: null, diff --git a/src/utils/reveal-file.js b/src/utils/reveal-file.js index 8aa4bfd..926ba15 100644 --- a/src/utils/reveal-file.js +++ b/src/utils/reveal-file.js @@ -1,3 +1,9 @@ +/** + * A helper function for opening the file explorer UI + * to a highlighted path of `fileName` (e.g. "Show in Finder" on macOS) + * using the builtin electron API. + * @param {String} fileName + **/ export default function revealFile(fileName) { const { shell } = require('electron'); shell.showItemInFolder(fileName); diff --git a/src/utils/styler.js b/src/utils/styler.js index a64503e..63130cc 100644 --- a/src/utils/styler.js +++ b/src/utils/styler.js @@ -26,7 +26,8 @@ * ``` */ export default function styler(styles, prefix) { - return function get_style_for_component(what='') { + // eslint-disable-next-line camelcase + return function get_style_for_component(what = '') { const k = `${prefix}${what !== '' ? '-' + what : ''}`; const def = styles[k]; if (!def) { From 4626bb8c73b609e5a0dcf655ca3a15f9d482d86e Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Thu, 21 Nov 2019 00:26:05 -0500 Subject: [PATCH 19/29] fix: copy paste error was trying to set delimiter as a boolean --- src/components/import-options/import-options.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/import-options/import-options.jsx b/src/components/import-options/import-options.jsx index 979f3a8..e4d8bf9 100644 --- a/src/components/import-options/import-options.jsx +++ b/src/components/import-options/import-options.jsx @@ -78,8 +78,8 @@ class ImportOptions extends PureComponent { Select delimiter
; }); diff --git a/src/constants/import-step.js b/src/constants/import-step.js deleted file mode 100644 index 3034140..0000000 --- a/src/constants/import-step.js +++ /dev/null @@ -1,7 +0,0 @@ -export const OPTIONS = 'OPTIONS'; -export const PREVIEW = 'PREVIEW'; - -export default { - OPTIONS, - PREVIEW -}; diff --git a/src/modules/export.js b/src/modules/export.js index 2e7498d..52572cf 100644 --- a/src/modules/export.js +++ b/src/modules/export.js @@ -398,7 +398,6 @@ export const startExport = () => { }); progress.on('progress', function(info) { - // debug('progress', info); dispatch(onProgress(info.percentage, info.transferred)); }); @@ -412,8 +411,6 @@ export const startExport = () => { const dest = fs.createWriteStream(exportData.fileName); debug('executing pipeline'); - - // TODO: lucas: figure out how to make onStarted(); dispatch(onStarted(source, dest, numDocsToExport)); stream.pipeline(source, progress, formatter, dest, function(err) { if (err) { @@ -453,7 +450,7 @@ export const cancelExport = () => { } debug('cancelling'); source.unpipe(); - // dest.end(); + debug('canceled by user'); dispatch({ type: CANCELED }); }; diff --git a/src/modules/import.js b/src/modules/import.js index 2190dda..609c7c2 100644 --- a/src/modules/import.js +++ b/src/modules/import.js @@ -1,4 +1,27 @@ /* eslint-disable valid-jsdoc */ +/** + * # Import + * + * @see startImport() for the primary entrypoint. + * + * ``` + * openImport() + * | [user specifies import options or defaults] + * closeImport() | startImport() + * | > cancelImport() + * ``` + * + * - [User actions for speficying import options] can be called once the modal has been opened + * - Once `startImport()` has been called, [Import status action creators] are created internally + * + * NOTE: lucas: Any values intended for internal-use only, such as the action + * creators for import status/progress, are called out with @api private + * doc strings. This way, they can still be exported as needed for testing + * without having to think deeply on whether they are being called from a top-level + * action or not. Not great, but it has saved me a considerable amount of time vs. + * larger scale refactoring/frameworks. + */ + import { promisify } from 'util'; import fs from 'fs'; const checkFileExists = promisify(fs.exists); @@ -14,12 +37,12 @@ import { appRegistryEmit } from 'modules/compass'; import detectImportFile from 'utils/detect-import-file'; import { createCollectionWriteStream } from 'utils/collection-stream'; -import createParser, { createProgressStream } from 'utils/parsers'; +import createParser, { createProgressStream } from 'utils/import-parser'; import createPreviewWritable, { createPeekStream } from 'utils/import-preview'; import createImportSizeGuesstimator from 'utils/import-size-guesstimator'; import { removeBlanksStream } from 'utils/remove-blanks'; -import { transformProjectedTypesStream } from 'utils/apply-import-types-and-projection'; +import { transformProjectedTypesStream } from 'utils/import-apply-types-and-projection'; import { createLogger } from 'utils/logger'; @@ -48,7 +71,8 @@ const SET_FIELD_TYPE = `${PREFIX}/SET_FIELD_TYPE`; const SET_STEP = `${PREFIX}/SET_STEP`; /** - * Initial state. + * ## Initial state. + * * @api private */ export const INITIAL_STATE = { @@ -71,6 +95,19 @@ export const INITIAL_STATE = { previewLoaded: false }; +/** + * ### Import status action creators + * + * @see startImport below. + * + * ``` + * STARTED > + * | *ERROR* || SET_GUESSTIMATED_TOTAL > + * | <-- PROGRESS --> + * | *FINISHED* + * ``` + */ + /** * @param {Number} progress * @param {Number} docsWritten @@ -113,7 +150,6 @@ export const onError = (error) => ({ }); /** - * * @param {Number} guesstimatedDocsTotal * @api private */ @@ -122,177 +158,6 @@ export const onGuesstimatedDocsTotal = (guesstimatedDocsTotal) => ({ guesstimatedDocsTotal: guesstimatedDocsTotal }); -/** - * The import module reducer. - * - * @param {Object} state - The state. - * @param {Object} action - The action. - * - * @returns {Object} The state. - */ -// eslint-disable-next-line complexity -const reducer = (state = INITIAL_STATE, action) => { - if (action.type === SET_GUESSTIMATED_TOTAL) { - return { - ...state, - guesstimatedDocsTotal: action.guesstimatedDocsTotal - }; - } - - if (action.type === SET_DELIMITER) { - return { - ...state, - delimiter: action.delimiter - }; - } - - if (action.type === SET_STEP) { - return { - ...state, - step: action.step - }; - } - - if (action.type === TOGGLE_INCLUDE_FIELD) { - const newState = { - ...state - }; - newState.fields = newState.fields.map((field) => { - if (field.path === action.path) { - field.checked = !field.checked; - } - return field; - }); - return newState; - } - - if (action.type === SET_FIELD_TYPE) { - const newState = { - ...state - }; - newState.fields = newState.fields.map((field) => { - if (field.path === action.path) { - field.checked = true; - field.type = action.bsonType; - } - return field; - }); - return newState; - } - - if (action.type === SET_PREVIEW) { - return { - ...state, - values: action.values, - fields: action.fields, - previewLoaded: true - }; - } - - if (action.type === SET_STOP_ON_ERRORS) { - return { - ...state, - stopOnErrors: action.stopOnErrors - }; - } - - if (action.type === SET_IGNORE_BLANKS) { - return { - ...state, - ignoreBlanks: action.ignoreBlanks - }; - } - - if (action.type === FILE_SELECTED) { - return { - ...state, - fileName: action.fileName, - fileType: action.fileType, - fileStats: action.fileStats, - fileIsMultilineJSON: action.fileIsMultilineJSON, - status: PROCESS_STATUS.UNSPECIFIED, - progress: 0, - docsWritten: 0, - source: undefined, - dest: undefined - }; - } - - if (action.type === FAILED) { - return { - ...state, - error: action.error, - status: PROCESS_STATUS.FAILED - }; - } - - if (action.type === STARTED) { - return { - ...state, - error: null, - progress: 0, - status: PROCESS_STATUS.STARTED, - source: action.source, - dest: action.dest - }; - } - - if (action.type === PROGRESS) { - return { - ...state, - progress: action.progress, - docsWritten: action.docsWritten - }; - } - - if (action.type === FINISHED) { - const isComplete = !( - state.error || state.status === PROCESS_STATUS.CANCELED - ); - return { - ...state, - status: isComplete ? PROCESS_STATUS.COMPLETED : state.status, - docsWritten: action.docsWritten, - source: undefined, - dest: undefined - }; - } - - if (action.type === CANCELED) { - return { - ...state, - status: PROCESS_STATUS.CANCELED, - source: undefined, - dest: undefined - }; - } - - /** - * Open the `` - */ - if (action.type === OPEN) { - return { - ...INITIAL_STATE, - isOpen: true - }; - } - - if (action.type === CLOSE) { - return { - ...state, - isOpen: false - }; - } - - if (action.type === FILE_TYPE_SELECTED) { - return { - ...state, - fileType: action.fileType - }; - } - return state; -}; - /** * @api public */ @@ -317,7 +182,9 @@ export const startImport = () => { const source = fs.createReadStream(fileName, 'utf8'); - // TODO: lucas: Support ignoreUndefined as an option to pass to driver? + /** + * TODO: lucas: Support ignoreUndefined as an option to pass to driver? + */ const dest = createCollectionWriteStream(dataService, ns, stopOnErrors); const progress = createProgressStream(size, function(err, info) { @@ -363,7 +230,7 @@ export const startImport = () => { dest, function(err, res) { /** - * refresh data (docs, aggregations) regardless of whether we have a + * Refresh data (docs, aggregations) regardless of whether we have a * partial import or full import */ dispatch(appRegistryEmit('refresh-data')); @@ -374,10 +241,6 @@ export const startImport = () => { if (err) { return dispatch(onError(err)); } - /** - * TODO: lucas: once import is finished, - * trigger a refresh on the documents view. - */ debug('done', err, res); dispatch(onFinished(dest.docsWritten)); dispatch(appRegistryEmit('import-finished', size, fileType)); @@ -412,6 +275,9 @@ export const cancelImport = () => { * Load a preview of the first few documents in the selected file * which is used to calculate an inital set of `fields` and `values`. * + * `loadPreviewDocs()` is only called internally when any state used + * for specifying import parsing is modified. + * * @param {String} fileName * @param {String} fileType * @api private @@ -422,11 +288,13 @@ const loadPreviewDocs = ( delimiter, fileIsMultilineJSON ) => { - return (dispatch, getState) => { + return (dispatch) => { /** * TODO: lucas: add dispatches for preview loading, error, etc. + * as needed. For the time being, its fast enough and we want + * errors/faults hard so we can figure out edge cases that + * actually need it. */ - const source = fs.createReadStream(fileName, 'utf8'); const dest = createPreviewWritable(); stream.pipeline( @@ -447,6 +315,10 @@ const loadPreviewDocs = ( }; }; +/** + * ### User actions for speficying import options + */ + /** * Mark a field to be included or excluded from the import. * @@ -503,9 +375,6 @@ export const selectImportFileName = (fileName) => { return promisify(detectImportFile)(fileName); }) .then((detected) => { - // TODO: lucas: make detect-import-file also detect delimiter like papaparse. - const delimiter = getState().importData.delimiter; - dispatch({ type: FILE_SELECTED, fileName: fileName, @@ -513,6 +382,11 @@ export const selectImportFileName = (fileName) => { fileIsMultilineJSON: detected.fileIsMultilineJSON, fileType: detected.fileType }); + + /** + * TODO: lucas: @see utils/detect-import-file.js for future delimiter detection. + */ + const delimiter = getState().importData.delimiter; dispatch( loadPreviewDocs( fileName, @@ -527,7 +401,7 @@ export const selectImportFileName = (fileName) => { }; /** - * Select the file type of the import. + * The user has manually selected the `fileType` of the import. * * @param {String} fileType * @api public @@ -540,6 +414,7 @@ export const selectImportFileType = (fileType) => { delimiter, fileIsMultilineJSON } = getState().importData; + dispatch({ type: FILE_TYPE_SELECTED, fileType: fileType @@ -554,31 +429,6 @@ export const selectImportFileType = (fileType) => { }; }; -/** - * Open the import modal. - * @api public - */ -export const openImport = () => ({ - type: OPEN -}); - -/** - * Close the import modal. - * @api public - */ -export const closeImport = () => ({ - type: CLOSE -}); - -/** - * Change pages within the modal. - * @api public - */ -export const setStep = (step) => ({ - type: SET_STEP, - step: step -}); - /** * Set the tabular delimiter. * @param {String} delimiter One of `,` for csv, `\t` for csv @@ -645,4 +495,197 @@ export const setIgnoreBlanks = (ignoreBlanks) => ({ ignoreBlanks: ignoreBlanks }); +/** + * ### Top-level modal visibility + */ + +/** + * Open the import modal. + * @api public + */ +export const openImport = () => ({ + type: OPEN +}); + +/** + * Close the import modal. + * @api public + */ +export const closeImport = () => ({ + type: CLOSE +}); + +/** + * The import module reducer. + * + * @param {Object} state - The state. + * @param {Object} action - The action. + * + * @returns {Object} The state. + */ +// eslint-disable-next-line complexity +const reducer = (state = INITIAL_STATE, action) => { + if (action.type === SET_GUESSTIMATED_TOTAL) { + return { + ...state, + guesstimatedDocsTotal: action.guesstimatedDocsTotal + }; + } + + if (action.type === SET_DELIMITER) { + return { + ...state, + delimiter: action.delimiter + }; + } + + if (action.type === SET_STEP) { + return { + ...state, + step: action.step + }; + } + + if (action.type === TOGGLE_INCLUDE_FIELD) { + const newState = { + ...state + }; + newState.fields = newState.fields.map((field) => { + if (field.path === action.path) { + field.checked = !field.checked; + } + return field; + }); + return newState; + } + + if (action.type === SET_FIELD_TYPE) { + const newState = { + ...state + }; + newState.fields = newState.fields.map((field) => { + if (field.path === action.path) { + // If a user changes a field type, automatically check it for them + // so they don't need an extra click or forget to click it an get frustrated + // like I did so many times :) + field.checked = true; + field.type = action.bsonType; + } + return field; + }); + return newState; + } + + if (action.type === SET_PREVIEW) { + return { + ...state, + values: action.values, + fields: action.fields, + previewLoaded: true + }; + } + + if (action.type === SET_STOP_ON_ERRORS) { + return { + ...state, + stopOnErrors: action.stopOnErrors + }; + } + + if (action.type === SET_IGNORE_BLANKS) { + return { + ...state, + ignoreBlanks: action.ignoreBlanks + }; + } + + if (action.type === FILE_SELECTED) { + return { + ...state, + fileName: action.fileName, + fileType: action.fileType, + fileStats: action.fileStats, + fileIsMultilineJSON: action.fileIsMultilineJSON, + status: PROCESS_STATUS.UNSPECIFIED, + progress: 0, + docsWritten: 0, + source: undefined, + dest: undefined + }; + } + + if (action.type === FAILED) { + return { + ...state, + error: action.error, + status: PROCESS_STATUS.FAILED + }; + } + + if (action.type === STARTED) { + return { + ...state, + error: null, + progress: 0, + status: PROCESS_STATUS.STARTED, + source: action.source, + dest: action.dest + }; + } + + if (action.type === PROGRESS) { + return { + ...state, + progress: action.progress, + docsWritten: action.docsWritten + }; + } + + if (action.type === FINISHED) { + const isComplete = !( + state.error || state.status === PROCESS_STATUS.CANCELED + ); + return { + ...state, + status: isComplete ? PROCESS_STATUS.COMPLETED : state.status, + docsWritten: action.docsWritten, + source: undefined, + dest: undefined + }; + } + + if (action.type === CANCELED) { + return { + ...state, + status: PROCESS_STATUS.CANCELED, + source: undefined, + dest: undefined + }; + } + + /** + * Open the `` + */ + if (action.type === OPEN) { + return { + ...INITIAL_STATE, + isOpen: true + }; + } + + if (action.type === CLOSE) { + return { + ...state, + isOpen: false + }; + } + + if (action.type === FILE_TYPE_SELECTED) { + return { + ...state, + fileType: action.fileType + }; + } + return state; +}; export default reducer; diff --git a/src/utils/bson-csv.js b/src/utils/bson-csv.js index 7398140..b617b59 100644 --- a/src/utils/bson-csv.js +++ b/src/utils/bson-csv.js @@ -17,6 +17,8 @@ * TODO: lucas: If we want to support types via CSV headers * for compatibility with mongoimport, that all happens in: * https://github.com/mongodb/mongo-tools/blob/master/mongoimport/typed_fields.go + * + * And https://www.npmjs.com/package/flat#transformkey can be used to prototype. */ /** diff --git a/src/utils/collection-stream.js b/src/utils/collection-stream.js index 3c067d4..4d2aae7 100644 --- a/src/utils/collection-stream.js +++ b/src/utils/collection-stream.js @@ -63,20 +63,18 @@ class WritableCollectionStream extends Writable { next(); }; - const execBatch = cb => { + const execBatch = (cb) => { const batchSize = this.batch.length; this.batch.execute((err, res) => { - // TODO: lucas: appears turning off retyableWrites - // gives a slightly different error but probably same problem? + /** + * TODO: lucas: appears turning off retyableWrites + * gives a slightly different error but probably same problem? + */ if ( err && Array.isArray(err.errorLabels) && err.errorLabels.indexOf('TransientTransactionError') ) { - debug( - 'NOTE: @lucas: this is a transient transaction error and is a bug in retryable writes.', - err - ); err = null; res = { nInserted: batchSize }; } @@ -84,8 +82,10 @@ class WritableCollectionStream extends Writable { if (err && !this.stopOnErrors) { console.log('stopOnErrors false. skipping', err); err = null; - // TODO: lucas: figure out how to extract finer-grained bulk op results - // from err in these cases. + /** + * TODO: lucas: figure out how to extract finer-grained bulk op results + * from err in these cases. + */ res = {}; } if (err) { @@ -105,20 +105,20 @@ class WritableCollectionStream extends Writable { debug('running _final()'); if (this.batch.length === 0) { - // debug('nothing left in buffer'); debug('%d docs written', this.docsWritten); this.printJobStats(); return callback(); } - // TODO: lucas: Reuse error wrangling from _write above. + /** + * TODO: lucas: Reuse error wrangling from _write above. + */ debug('draining buffered docs', this.batch.length); this.batch.execute((err, res) => { this.captureStatsForBulkResult(err, res); this.docsWritten += this.batch.length; this.printJobStats(); this.batch = null; - // debug('buffer drained', err, res); debug('%d docs written', this.docsWritten); callback(err); }); @@ -134,7 +134,7 @@ class WritableCollectionStream extends Writable { 'ok' ]; - keys.forEach(k => { + keys.forEach((k) => { this._stats[k] += res[k] || 0; }); if (!err) return; diff --git a/src/utils/detect-import-file.js b/src/utils/detect-import-file.js index 65f2651..c46243b 100644 --- a/src/utils/detect-import-file.js +++ b/src/utils/detect-import-file.js @@ -9,8 +9,21 @@ const debug = createLogger('detect-import-file'); const DEFAULT_FILE_TYPE = 'json'; -// TODO: Include more heuristics. Ideally the user just picks the file -// and we auto-detect the various formats/options. +// const importOptions = { +// fileIsMultilineJSON: false, +// fileType: DEFAULT_FILE_TYPE +// }; + +/** + * Guess the `importOptions` to use for parsing the contents of + * `fileName` without looking at the entire file. + * + * @param {String} fileName + * @param {Function} done (err, importOptions) + * + * TODO: lucas: Include more heuristics. Ideally the user just picks the file + * and we auto-detect the various formats/options. + **/ function detectImportFile(fileName, done) { debug('peeking at', fileName); @@ -32,7 +45,10 @@ function detectImportFile(fileName, done) { fileType = DEFAULT_FILE_TYPE; } - // TODO: lucas: papaparse guessDelimiter + /** + * TODO: lucas: guess delimiter like papaparse in the future. + * https://github.com/mholt/PapaParse/blob/49170b76b382317356c2f707e2e4191430b8d495/docs/resources/js/papaparse.js#L1264 + */ debug('swapping'); swap('done'); }); diff --git a/src/utils/dotnotation.js b/src/utils/dotnotation.js new file mode 100644 index 0000000..3a48242 --- /dev/null +++ b/src/utils/dotnotation.js @@ -0,0 +1,35 @@ +import { flatten, unflatten } from 'flat'; + +/** + * Converts any nested objects into a single depth object with `dotnotation` keys. + * @example + * ```javascript + * dotnotation.serialize({_id: 'arlo', collar: {size: 14}}); + * >> {_id: 'arlo', 'collar.size': 14} + * ``` + * @param {Object} obj + * @returns {Object} + */ +export function serialize(obj) { + /** + * TODO: lucas: bson type support. For now, drop. + */ + return flatten(obj); +} + +/** + * Converts an object using dotnotation to a full, nested object. + * @example + * ```javascript + * dotnotation.deserialize({_id: 'arlo', 'collar.size': 14}); + * >> {_id: 'arlo', collar: {size: 14}} + * ``` + * @param {Object} obj + * @returns {Object} + */ +export function deserialize(obj) { + /** + * TODO: lucas: bson type support. For now, drop. + */ + return unflatten(obj); +} diff --git a/src/utils/formatters.js b/src/utils/formatters.js index a0127b9..5cdfdce 100644 --- a/src/utils/formatters.js +++ b/src/utils/formatters.js @@ -2,6 +2,10 @@ /* eslint-disable callback-return */ /* eslint-disable complexity */ +/** + * TODO: lucas: rename `export-formatters` + */ + import csv from 'fast-csv'; import { EJSON } from 'bson'; import { Transform } from 'stream'; @@ -74,6 +78,6 @@ const formatTabularRow = function(doc, opts = { delimiter: '.' }) { export const createCSVFormatter = function() { return csv.format({ headers: true, - transform: row => formatTabularRow(row) + transform: (row) => formatTabularRow(row) }); }; diff --git a/src/utils/apply-import-types-and-projection.js b/src/utils/import-apply-types-and-projection.js similarity index 86% rename from src/utils/apply-import-types-and-projection.js rename to src/utils/import-apply-types-and-projection.js index e6fd044..065cd76 100644 --- a/src/utils/apply-import-types-and-projection.js +++ b/src/utils/import-apply-types-and-projection.js @@ -31,15 +31,15 @@ function transformProjectedTypes(fields, data) { return keys.reduce(function(doc, key) { const def = getProjection(fields, key); - // TODO: lucas: Relocate removeEmptyStrings() here? - // Avoid yet another recursive traversal of every document. + /** + * TODO: lucas: Relocate removeEmptyStrings() here? + * Avoid yet another recursive traversal of every document. + */ if (def && !def.checked) { - // debug('dropping unchecked key', key); return doc; } if (def.type && bsonCSV[def.type] && !isObjectLike(data[key])) { doc[key] = bsonCSV[def.type].fromString(data[key]); - // debug('deserialized %s', key, { from: data[key], to: doc[key] }); } else { doc[key] = transformProjectedTypes(fields, data[key]); } diff --git a/src/utils/apply-import-types-and-projection.spec.js b/src/utils/import-apply-types-and-projection.spec.js similarity index 95% rename from src/utils/apply-import-types-and-projection.spec.js rename to src/utils/import-apply-types-and-projection.spec.js index fe52134..b331d74 100644 --- a/src/utils/apply-import-types-and-projection.spec.js +++ b/src/utils/import-apply-types-and-projection.spec.js @@ -1,6 +1,6 @@ -import apply from './apply-import-types-and-projection'; +import apply from './import-apply-types-and-projection'; -describe('apply-import-types-and-projection', () => { +describe('import-apply-types-and-projection', () => { it('should include all fields by default', () => { const res = apply([{ path: '_id', checked: true, type: 'String' }], { _id: 'arlo' diff --git a/src/utils/parsers.js b/src/utils/import-parser.js similarity index 71% rename from src/utils/parsers.js rename to src/utils/import-parser.js index 11eec0c..d03250d 100644 --- a/src/utils/parsers.js +++ b/src/utils/import-parser.js @@ -7,7 +7,7 @@ import parseJSON from 'parse-json'; import throttle from 'lodash.throttle'; import progressStream from 'progress-stream'; -const debug = createLogger('parsers'); +const debug = createLogger('import-parser'); /** * A transform stream that turns file contents in objects @@ -17,7 +17,6 @@ const debug = createLogger('parsers'); */ export const createCSVParser = function({ delimiter = ',' } = {}) { return csv({ - // strict: true, separator: delimiter }); }; @@ -74,22 +73,47 @@ export const createJSONParser = function({ return stream; }; -// TODO: lucas: move progress to its own module? +/** + * How often to update progress via a leading throttle + */ +const PROGRESS_UPDATE_INTERVAL = 250; + +/** + * Since we have no idea what the size of a document + * will be as part of an import before we've started, + * just pick a nice number of bytes :) + * + * @see utils/import-size-guesstimator + * will figure out a more realistic number once the documents start + * flowing through the pipechain. + */ +const NAIVE_AVERAGE_DOCUMENT_SIZE = 800; +/** + * Creates a transform stream for measuring progress at any point in the pipechain + * backing an import operation. The `onProgress` callback will be throttled to only update once + * every `${PROGRESS_UPDATE_INTERVAL}ms`. + * + * @param {Number} fileSize The total file size + * @param {Function} onProgress Your callback for progress updates + * @returns {stream.Transform} + */ export const createProgressStream = function(fileSize, onProgress) { const progress = progressStream({ objectMode: true, - length: fileSize / 800, - time: 500 + length: fileSize / NAIVE_AVERAGE_DOCUMENT_SIZE, + time: PROGRESS_UPDATE_INTERVAL // NOTE: ask lucas how time is different from an interval here. }); // eslint-disable-next-line camelcase function update_import_progress_throttled(info) { - // debug('progress', info); - // dispatch(onProgress(info.percentage, dest.docsWritten)); onProgress(null, info); } - const updateProgress = throttle(update_import_progress_throttled, 500); + const updateProgress = throttle( + update_import_progress_throttled, + PROGRESS_UPDATE_INTERVAL, + { leading: true } + ); progress.on('progress', updateProgress); return progress; }; diff --git a/src/utils/parsers.spec.js b/src/utils/import-parser.spec.js similarity index 95% rename from src/utils/parsers.spec.js rename to src/utils/import-parser.spec.js index 15136b4..44f789c 100644 --- a/src/utils/parsers.spec.js +++ b/src/utils/import-parser.spec.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import stream from 'stream'; -import createParser from './parsers'; +import createParser from './import-parser'; const TEST_DIR = path.join(__dirname, '..', '..', '..', 'test'); const FIXTURES = { @@ -37,7 +37,7 @@ function runParser(src, parser) { }); } -describe('parsers', () => { +describe('import-parser', () => { describe('json', () => { it('should parse a file', () => { return runParser(FIXTURES.GOOD_JSON, createParser()).then((docs) => { @@ -94,8 +94,9 @@ describe('parsers', () => { expect(docs).to.have.length(3); }); }); - // TODO: lucas: See other todos on having to disable strict - // for csv parser for import preview. + /** + * TODO: lucas: Revisit and unskip if we really want csv to be strict. + */ describe.skip('errors', () => { let parseError; before((done) => { diff --git a/src/utils/import-preview.js b/src/utils/import-preview.js index f2cc50c..8bb26ed 100644 --- a/src/utils/import-preview.js +++ b/src/utils/import-preview.js @@ -1,16 +1,12 @@ import { Writable } from 'stream'; import peek from 'peek-stream'; -import createParser from './parsers'; -import { flatten } from 'flat'; +import createParser from './import-parser'; +import dotnotation from './dotnotation'; + import { detectType } from './bson-csv'; import { createLogger } from './logger'; const debug = createLogger('import-preview'); -const warn = (msg, ...args) => { - // eslint-disable-next-line no-console - console.warn('compass-import-export:import-preview: ' + msg, args); -}; - /** * Peek the first 20k of a file and parse it. * @@ -36,14 +32,10 @@ export const createPeekStream = function( }); }; -/** - * TODO: lucas: Preview could have partial objects if - * spill over into next buffer. Can we back pressure against - * the input source for real instead of this hacky impl? - */ - /** * Collects 10 parsed documents from createPeekStream(). + * + * @option {Number} MAX_SIZE The number of documents/rows we want to preview [Default `10`] * @returns {stream.Writable} */ export default function({ MAX_SIZE = 10 } = {}) { @@ -61,13 +53,13 @@ export default function({ MAX_SIZE = 10 } = {}) { } this.docs.push(doc); - // TODO: lucas: Don't unflatten bson internal props. - const flat = flatten(doc); + const docAsDotnotation = dotnotation.serialize(doc); if (this.fields.length === 0) { // eslint-disable-next-line prefer-const - for (let [key, value] of Object.entries(flat)) { + for (let [key, value] of Object.entries(docAsDotnotation)) { // TODO: lucas: Document this weird bug I found with my apple health data. + // eslint-disable-next-line no-control-regex key = key.replace(/[^\x00-\x7F]/g, ''); this.fields.push({ path: key, @@ -78,18 +70,14 @@ export default function({ MAX_SIZE = 10 } = {}) { debug('set fields', this.fields, { from: doc }); } - const flattenedKeys = Object.keys(flat); - - // TODO: lucas: For JSON, use schema parser or something later to - // handle sparse/polymorphic. For now, the world is pretty tabular - // and wait for user reports. - if (flattenedKeys.length !== this.fields.length) { - warn('invariant detected!', { + const keys = Object.keys(docAsDotnotation); + if (keys.length !== this.fields.length) { + debug('invariant detected!', { expected: this.fields.map((f) => f.path), - got: flattenedKeys + got: keys }); } - this.values.push(Object.values(flat)); + this.values.push(Object.values(docAsDotnotation)); return next(null); } diff --git a/src/utils/import-size-guesstimator.spec.js b/src/utils/import-size-guesstimator.spec.js index 232d648..f411896 100644 --- a/src/utils/import-size-guesstimator.spec.js +++ b/src/utils/import-size-guesstimator.spec.js @@ -14,8 +14,10 @@ import { createCSVParser } from './parsers'; import createImportSizeGuesstimator from './import-size-guesstimator'; import { pipeline } from 'stream'; -// TODO: lucas: This works functionally in electron but can't -// figure out how/why in mocha-webpack. +/** + * TODO: lucas: This works functionally in electron but can't + * figure out how/why mocha-webpack until we get electron@6 + */ describe.skip('guesstimator', () => { it('should guess', function(done) { this.timeout(5000); From 784e57556384197215810de1bc82407d03c567f1 Mon Sep 17 00:00:00 2001 From: Lucas Hrabovsky Date: Tue, 3 Dec 2019 19:50:11 -0500 Subject: [PATCH 29/29] fix tests --- electron/renderer/index.js | 2 +- src/modules/import.js | 10 ---------- src/utils/import-size-guesstimator.spec.js | 2 +- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/electron/renderer/index.js b/electron/renderer/index.js index 24c6849..96da467 100644 --- a/electron/renderer/index.js +++ b/electron/renderer/index.js @@ -21,7 +21,7 @@ import 'less/global.less'; /** * Customize data service for your sandbox. */ -const NS = 'test.people_imported'; +const NS = 'lucas_apple_health_data.sleep'; import Connection from 'mongodb-connection-model'; const connection = new Connection({ diff --git a/src/modules/import.js b/src/modules/import.js index 609c7c2..629e820 100644 --- a/src/modules/import.js +++ b/src/modules/import.js @@ -32,7 +32,6 @@ import stripBomStream from 'strip-bom-stream'; import mime from 'mime-types'; import PROCESS_STATUS from 'constants/process-status'; -import STEP from 'constants/import-step'; import { appRegistryEmit } from 'modules/compass'; import detectImportFile from 'utils/detect-import-file'; @@ -68,7 +67,6 @@ const SET_STOP_ON_ERRORS = `${PREFIX}/SET_STOP_ON_ERRORS`; const SET_IGNORE_BLANKS = `${PREFIX}/SET_IGNORE_BLANKS`; const TOGGLE_INCLUDE_FIELD = `${PREFIX}/TOGGLE_INCLUDE_FIELD`; const SET_FIELD_TYPE = `${PREFIX}/SET_FIELD_TYPE`; -const SET_STEP = `${PREFIX}/SET_STEP`; /** * ## Initial state. @@ -77,7 +75,6 @@ const SET_STEP = `${PREFIX}/SET_STEP`; */ export const INITIAL_STATE = { isOpen: false, - step: STEP.OPTIONS, progress: 0, error: null, fileName: '', @@ -539,13 +536,6 @@ const reducer = (state = INITIAL_STATE, action) => { }; } - if (action.type === SET_STEP) { - return { - ...state, - step: action.step - }; - } - if (action.type === TOGGLE_INCLUDE_FIELD) { const newState = { ...state diff --git a/src/utils/import-size-guesstimator.spec.js b/src/utils/import-size-guesstimator.spec.js index f411896..e7ea36e 100644 --- a/src/utils/import-size-guesstimator.spec.js +++ b/src/utils/import-size-guesstimator.spec.js @@ -10,7 +10,7 @@ // import-size-guesstimator.js?6e25:46 bytesPerDoc 458.752 // import-size-guesstimator.js?6e25:47 docs seen 1000 // import-size-guesstimator.js?6e25:48 est docs 202250.81743512835 -import { createCSVParser } from './parsers'; +import { createCSVParser } from './import-parser'; import createImportSizeGuesstimator from './import-size-guesstimator'; import { pipeline } from 'stream';
+ {v} {v}{v}
{index + 1}
{index + 1}
, fields)}
, fields)}
{v}