diff --git a/lib/actions/editor.js b/lib/actions/editor.js index c950af20..5109cd37 100644 --- a/lib/actions/editor.js +++ b/lib/actions/editor.js @@ -10,7 +10,12 @@ import { EDITOR_UPDATE_BODY, EDITOR_SET_VIZ_SCRIPT, EDITOR_SET_TRANSFORM_SCRIPT, - EDITOR_REMOVE_SECTION + EDITOR_REMOVE_SECTION, + EDITOR_SET_SCHEMA, + EDITOR_SET_BODY_VIEW, + EDITOR_SET_COL_ORDER, + EDITOR_SET_ROW_ORDER, + EDITOR_SET_BODY_ERROR } from '../constants/editor' import { @@ -26,7 +31,7 @@ const blankDataset = {} // initDataset resets the editor to a blank dataset // TODO - consider renaming this to like "initEditor" or something to avoid confusion with "newDataset", "saveDataset" actions -export function initDataset (name = '', dataset = blankDataset, transformScript = '', vizScript = '', body = '') { +export function initDataset (name = '', dataset = blankDataset, transformScript = '', vizScript = '', body = undefined) { return { type: EDITOR_INIT_DATASET, name, @@ -132,6 +137,16 @@ export function setStructure (structure = {}) { } } +// setSchema updates/replaces the schema in +// the dataset.structure section +// as well as the the schema string section +export function setSchema (schema = '') { + return { + type: EDITOR_SET_SCHEMA, + schema + } +} + // setViz replaces/updates the editor viz object export function setViz (viz = {}) { return { @@ -188,3 +203,31 @@ export function removeSection (section = '') { section } } + +export function setBodyView (view = '') { + return { + type: EDITOR_SET_BODY_VIEW, + view + } +} + +export function setColOrder (order) { + return { + type: EDITOR_SET_COL_ORDER, + order + } +} + +export function setRowOrder (order) { + return { + type: EDITOR_SET_ROW_ORDER, + order + } +} + +export function setBodyError (error) { + return { + type: EDITOR_SET_BODY_ERROR, + error + } +} diff --git a/lib/components/dataset/Body.js b/lib/components/dataset/Body.js index 317ac1e5..fe9138e0 100644 --- a/lib/components/dataset/Body.js +++ b/lib/components/dataset/Body.js @@ -7,7 +7,7 @@ import Spinner from '../chrome/Spinner' import DatasetRefProps from '../../propTypes/datasetRefProps' import Button from '../chrome/Button' import HandsonTable from '../HandsonTable' -import { extractSchema } from '../../selectors/dataset' +import { extractColumnHeaders } from '../../selectors/dataset' import BodyReadOnly from './BodyReadOnly' export default class Body extends Base { @@ -20,7 +20,7 @@ export default class Body extends Base { makeColHeaders () { const { datasetRef } = this.props - const schema = extractSchema(datasetRef) + const schema = extractColumnHeaders(datasetRef) return schema && schema.map(i => i.title) } @@ -49,7 +49,7 @@ export default class Body extends Base { // button for switching views - if (body && datasetRef.dataset && datasetRef.dataset.structure && datasetRef.dataset.structure.format === 'csv') { + if (body && datasetRef.dataset && datasetRef.dataset.structure && datasetRef.dataset.structure.depth === 2) { return } diff --git a/lib/components/editor/EditBody.js b/lib/components/editor/EditBody.js index b5f941f4..03087c8c 100644 --- a/lib/components/editor/EditBody.js +++ b/lib/components/editor/EditBody.js @@ -1,39 +1,126 @@ +/* globals alert */ + import React from 'react' import PropTypes from 'prop-types' import HotTable from 'react-handsontable' import MonacoEditor from 'react-monaco-editor' -import { extractSchema } from '../../selectors/dataset' +import { + generateSchemaFromRow, + generateCollapsedChanges, + generateMatchingSchemaAndBody +} from '../../qri/generate' import Base from '../Base' +import ValidSelect from '../form/ValidSelect' import Button from '../chrome/Button' +import cloneDeep from 'clone-deep' + export default class EditBody extends Base { constructor (props) { super(props); [ - 'makeColHeaders', 'onAddBody', 'onRemoveBody', 'onBeforeChange', 'onMonacoChange', - 'renderEditor' + 'renderEditor', + 'onSetView', + 'onCreateColumns', + 'onCreateRows', + 'onRemoveColumns', + 'onRemoveRows', + 'setColumnHeaders', + 'schemaItemsToCols', + 'onMoveColumns', + 'setReOrder', + 'onMoveRows', + 'onCollapseChanges' ].forEach((m) => { this[m] = this[m].bind(this) }) } - makeColHeaders () { - const { datasetRef } = this.props - const schema = extractSchema(datasetRef) - return schema && schema.map(i => i.title) + componentWillMount () { + const { bodyView, structure, body: prevBody, columnHeaders, colOrder, rowOrder } = this.props + const check = generateMatchingSchemaAndBody(bodyView, structure, prevBody, columnHeaders, colOrder, rowOrder) + if (typeof check === 'string') { + this.props.setBodyError(check) + return + } + if (bodyView === 'table') { + const { body, schema, colOrder, rowOrder } = check + if (body) { + this.props.onSetBody(body) + } + if (schema) { + this.props.setSchema(schema) + } + if (colOrder) { + this.props.setColOrder(colOrder) + } + if (rowOrder) { + this.props.setRowOrder(rowOrder) + } + this.props.setBodyError() + } + } + + componentWillUnmount () { + if (this.props.bodyView === 'table') { + this.onCollapseChanges() + } + } + + onCollapseChanges () { + const { body, columnHeaders, colOrder, rowOrder } = this.props + const changed = generateCollapsedChanges(body, columnHeaders, colOrder, rowOrder) + if (changed) { + this.props.setSchema(changed.schema) + this.props.onSetBody(changed.body) + this.props.setRowOrder(changed.rowOrder) + this.props.setColOrder(changed.colOrder) + } + } + + setColumnHeaders (columnHeaders) { + this.props.setSchema({ + type: 'array', + items: { + type: 'array', + items: columnHeaders + } + }) + } + + schemaItemsToCols (items) { + return !items || items.map(i => i.title) } onAddBody () { - // TODO - check structure & produce proper data format - this.props.onSetBody([['', ''], ['', '']]) + // if there is no body before create a basic 2D array. + // if we are in bodyView json parse that array + const { bodyView } = this.props + var body = [['', ''], ['', '']] + if (bodyView === 'json') { + this.props.onSetBody(JSON.stringify(body, null, 2)) + return + } + // if we are in not in json view generate a schema from that body + // set the schema, the col and row order, and the body + const newSchema = generateSchemaFromRow(body[0]) + this.props.setSchema(newSchema) + this.props.setColOrder(newSchema.items.items.map((elem, index) => index)) + this.props.setRowOrder(body[0].map((elem, index) => index)) + this.props.onSetBody(body) + } + + onSetView (name, value) { + this.props.onSetView(value) } onRemoveBody () { - this.props.onSetBody('') + this.props.onSetBody() + this.props.onRemove('body') } onMonacoChange (body) { @@ -47,38 +134,144 @@ export default class EditBody extends Base { this.props.onUpdateBody(changes) } + onCreateColumns (index, amount, source) { + if (source === 'ObserveChanges.change') { + return + } + const { columnHeaders } = this.props + var newColumnHeaders = cloneDeep(columnHeaders) + // adding a new column + newColumnHeaders.splice(index, 0, { 'title': 'col_' + (index + amount) }) + + const { colOrder } = this.props + var newColOrder = colOrder.map(position => position < index ? position : position + amount) + newColOrder.splice(index, 0, index) + + this.setColumnHeaders(newColumnHeaders) + this.props.setColOrder(newColOrder) + } + + onCreateRows (index, amount, source) { + if (source === 'ObserveChanges.change') { + return + } + const { rowOrder } = this.props + var newRowOrder = rowOrder.map(position => position < index ? position : position + amount) + newRowOrder.splice(index, 0, index) + + this.props.setRowOrder(newRowOrder) + } + + onRemoveColumns (index, amount) { + const { columnHeaders } = this.props + var newColumnHeaders = cloneDeep(columnHeaders) + newColumnHeaders.splice(index, amount) + + const { colOrder } = this.props + var newColOrder = colOrder + .filter(position => position < index || position >= index + amount) + .map(position => position < index + amount ? position : position - amount) + + this.setColumnHeaders(newColumnHeaders) + this.props.setColOrder(newColOrder) + } + + onRemoveRows (index, amount) { + const { rowOrder } = this.props + var newRowOrder = rowOrder + .filter(position => position < index || position >= index + amount) + .map(position => position < index + amount ? position : position - amount) + + this.props.setRowOrder(newRowOrder) + } + + setReOrder (elems, target, mapped, order, source) { + if (mapped === target) { + // no changes, just return + return + } + const len = elems.length + const newOrder = order.map((lastPos, origPos) => { + if (elems.includes(origPos)) { + const delta = elems.indexOf(origPos) + return target + delta + } + if (mapped > target) { + if (lastPos < target || lastPos > mapped) { + return lastPos + } + return lastPos + len + } + if (mapped < (target + len - 1)) { + if (lastPos < mapped || lastPos > (target + len - 1)) { + return lastPos + } + return lastPos - len + } + }) + + if (source === 'row') { + this.props.setRowOrder(newOrder) + return + } + this.props.setColOrder(newOrder) + } + + onMoveColumns (columns, target) { + // const { columnHeaders } = this.props + const { colOrder } = this.props + if (columns.length === 0 || colOrder.length <= columns[0]) { + alert('somehow got a move column request without any columns specified') + return + } + const mapped = colOrder[columns[0]] + this.setReOrder(columns, target, mapped, colOrder, 'col') + } + + onMoveRows (rows, target) { + const { rowOrder } = this.props + if (rows.length === 0 || rowOrder.length <= rows[0]) { + alert('somehow got a move row request without any rows specified') + return + } + const mapped = rowOrder[rows[0]] + if (mapped < target) { + target = target - rows.length + } + this.setReOrder(rows, target, mapped, rowOrder, 'row') + } + renderEditor () { - const { body, structure } = this.props + const { + body, + bodyView, + columnHeaders + } = this.props const options = { selectOnLineNumbers: true } - switch (structure.format) { + switch (bodyView) { case 'json': - let stringBody = body - // if we're JSON, pretty print so things are less gross - if (body.constructor !== String) { - // TODO - what happens in the unlikely event that this JSON data is corrupt? - stringBody = JSON.stringify(stringBody, null, 2) - } return ( ) - case 'csv': + case 'table': default: return ( ) } } template (css) { - const { body, structure } = this.props + const { + body, + bodyView, + bodyError + } = this.props - if (!body) { + if (body === undefined) { return (
@@ -119,10 +322,18 @@ export default class EditBody extends Base {
) @@ -149,6 +360,22 @@ export default class EditBody extends Base { flex: '2 1 95%', width: '100%', overflow: 'auto' + }, + select: { + width: 100, + display: 'inline-block' + }, + columnHeaders: { + width: 200, + display: 'inline-block', + marginLeft: 20 + }, + error: { + margin: '30px', + color: '#ffffff', + padding: '30px', + background: 'rgba(255, 255, 255, 0.1)', + borderRadius: 5 } } } diff --git a/lib/components/editor/EditStructure.js b/lib/components/editor/EditStructure.js index 3569e935..67bd8d13 100644 --- a/lib/components/editor/EditStructure.js +++ b/lib/components/editor/EditStructure.js @@ -27,14 +27,19 @@ export default class EditStructure extends Base { onAddStructure () { this.props.onChangeStructure({ - format: 'json', - schema: { type: 'array' } + format: 'json' }) + this.props.onChangeSchema('') } template (css) { - const { structure } = this.props - + const { structure, onChangeSchema } = this.props + var schema = structure.schema + if (typeof schema !== 'string') { + schema = JSON.stringify(schema, null, 2) + } else if (!schema) { + schema = '' + } if (!Object.keys(structure).length) { return (
@@ -54,6 +59,8 @@ export default class EditStructure extends Base {
diff --git a/lib/components/editor/Editor.js b/lib/components/editor/Editor.js index e6ee8e3c..3b426406 100644 --- a/lib/components/editor/Editor.js +++ b/lib/components/editor/Editor.js @@ -3,7 +3,8 @@ import React from 'react' import PropTypes from 'prop-types' import { Route, Switch } from 'react-router' import { isEmpty, isEmptyObj } from '../../utils/reflect' -import { Parser } from 'json2csv' + +import { generateParsedBodyAndStructure } from '../../qri/generate' import Results from './Results' import EditMeta from './EditMeta' @@ -44,42 +45,40 @@ export default class Editor extends Base { } onRun () { - let { name, dataset, transformScript, vizScript, body } = this.props - dataset = Object.assign({}, dataset, { name }) - - // TODO - this probably shouldn't happen here - // the schema editor needs to work on a string, our dataset.structure.schema needs JSON - if (dataset.structure && dataset.structure.schema && dataset.structure.schema.constructor === String) { - try { - dataset.structure = Object.assign({}, dataset.structure, { schema: JSON.parse(dataset.structure.schema) }) - } catch (e) { - alert('dataset schema is invalid, plz fix before running') - return - } + let { name, dataset, transformScript, vizScript, body, colOrder, rowOrder } = this.props + var validDataset = Object.assign({}, dataset, { name }) + + const parsed = generateParsedBodyAndStructure(body, validDataset.structure, colOrder, rowOrder) + + // if parsed is a string, that means an error has returned + if (typeof parsed === 'string') { + alert(parsed + ', please fix before attempting to save the dataset.') + return } - if (body && body.constructor === String) { - try { - body = JSON.parse(body) - } catch (e) { - alert('dataset body is invalid plz fix before running') - return - } + // otherwise, parsed is an object with a parsed.schema and parsed.body + // containing a well parsed schema and body + if (!validDataset.structure) { + validDataset.structure = {} + } + if (parsed.schema) { + validDataset.structure['schema'] = parsed.schema } - if (body && dataset && dataset.structure && dataset.structure.format === 'csv') { - // TODO - set noHeader based on dataset.structure.formatConfig - body = new Parser({ header: false }).parse(body) - } else { - body = JSON.stringify(body) + // all datasets sent over from the api are in json + // if they were previously made in csv, they will converted back to csv + // once they are sent to the Qri backend + if (validDataset.structure) { + validDataset.structure['format'] = 'json' } - if (body === '""') { - body = '' + + if (parsed.body) { + // we need to pass along a stringified version of body! + parsed.body = JSON.stringify(parsed.body) } this.setState({ datasetError: '', datasetMessage: '', results: undefined, loading: true }) - - return { name, dataset, transformScript, vizScript, body } + return { name, dataset: validDataset, transformScript, vizScript, body: parsed.body } } onDryRun () { @@ -128,10 +127,45 @@ export default class Editor extends Base { } template (css) { - const { resultTab, datasetError, datasetMessage, results } = this.state - const { match, name, dataset, transformScript, vizScript, body } = this.props + const { + resultTab, + datasetError, + datasetMessage, + results + } = this.state + + const { + match, + name, + dataset, + transformScript, + vizScript, + body, + bodyView, + columnHeaders, + colOrder, + rowOrder, + bodyError, + id + } = this.props + // TODO - make use of setCommit func props - const { setName, setMeta, setStructure, setTransform, setViz, setTransformScript, setVizScript, setBody, updateBody, id } = this.props + const { + setName, + setMeta, + setStructure, + setSchema, + setTransform, + setViz, + setTransformScript, + setVizScript, + setBody, + setBodyView, + updateBody, + setColOrder, + setRowOrder, + setBodyError + } = this.props if (!id) { return @@ -169,6 +203,7 @@ export default class Editor extends Base { )} @@ -205,6 +240,19 @@ export default class Editor extends Base { structure={dataset.structure} onSetBody={setBody} onUpdateBody={updateBody} + onRemove={this.props.removeSection} + onSetView={setBodyView} + bodyView={bodyView} + setSchema={setSchema} + columnHeaders={columnHeaders} + onChangeSchema={setSchema} + onChangeStructure={setStructure} + setRowOrder={setRowOrder} + setColOrder={setColOrder} + rowOrder={rowOrder} + colOrder={colOrder} + bodyError={bodyError} + setBodyError={setBodyError} /> )} /> diff --git a/lib/components/form/StructureForm.js b/lib/components/form/StructureForm.js index 520b1cba..c96612ae 100644 --- a/lib/components/form/StructureForm.js +++ b/lib/components/form/StructureForm.js @@ -5,8 +5,8 @@ import MonacoEditor from 'react-monaco-editor' import Base from '../Base' // import ValidTextarea from './ValidTextarea' -import ValidSelect from './ValidSelect' -import StructureFormatConfig from './StructureFormatConfig' +// import ValidSelect from './ValidSelect' +// import StructureFormatConfig from './StructureFormatConfig' // import EditSchema from '../schema/EditSchema' export default class StructureForm extends Base { @@ -14,8 +14,7 @@ export default class StructureForm extends Base { super(props); [ - 'onChange', - 'onChangeSchema' + 'onChange' ].forEach((m) => { this[m] = this[m].bind(this) }) } @@ -23,24 +22,18 @@ export default class StructureForm extends Base { this.props.onChange(name, value) } - onChangeSchema (value) { - this.props.onChange('schema', value) - } - template (css) { - const { structure, validation, onChange, showHelpText } = this.props + const { schema, onChangeSchema } = this.props + // const { structure, validation, onChange, showHelpText, onChangeSchema } = this.props const options = { selectOnLineNumbers: true } - let schema = structure.schema - if (schema.constructor === Object) { - schema = JSON.stringify(schema) - } - return (
+ {/* removing the option for users to choose csv. All frontend structures will be
+ + */}

Schema

schemas are expressed as a json-schema

@@ -68,7 +62,7 @@ export default class StructureForm extends Base { theme='vs-dark' value={schema} options={options} - onChange={this.onChangeSchema} + onChange={onChangeSchema} // editorDidMount={this.editorDidMount} />
diff --git a/lib/components/form/ValidCheck.js b/lib/components/form/ValidCheck.js new file mode 100644 index 00000000..15307509 --- /dev/null +++ b/lib/components/form/ValidCheck.js @@ -0,0 +1,61 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import Base from '../Base' + +export default class ValidCheck extends Base { + template (css) { + const { label, name, className, checked, showError, error, helpText, showHelpText, onChange, labelTop } = this.props + const errorClass = (error && showError) ? 'has-error' : '' + const labelPosition = labelTop ? 'form-control-top' : 'form-control-bottom' + const inputPosition = labelTop ? 'form-control-bottom' : 'form-control-top' + return ( +
+
+ { onChange(name, e.target.value, e) }} /> + {label &&
} +
+ {(error !== '' && showError) ?
{error}
: undefined} + {(helpText && showHelpText) &&
{helpText}
} +
+ ) + } + + styles () { + return { + } + } +} + +ValidCheck.propTypes = { + // if provided it'll create a label element to accompany the field + label: PropTypes.string, + // required name for the field + name: PropTypes.string.isRequired, + // className will set on the containing div + className: PropTypes.string, + // whether the input starts checked + checked: PropTypes.bool, + showError: PropTypes.bool, + // value to display in the field + value: PropTypes.string, + // short message to help the user + helpText: PropTypes.string, + // weather to show help text or not + showHelpText: PropTypes.bool, + // change handler func. will be called with (name, value, event) + onChange: PropTypes.func.isRequired, + // show label on top + labelTop: PropTypes.bool, + // should a "none" option be allowed? + allowEmpty: PropTypes.bool +} + +ValidCheck.defaultProps = { + name: undefined, + showError: true, + error: undefined, + helpText: '', + showHelpText: false, + allowEmpty: true +} diff --git a/lib/constants/editor.js b/lib/constants/editor.js index b0d35016..9e59c81f 100644 --- a/lib/constants/editor.js +++ b/lib/constants/editor.js @@ -10,3 +10,8 @@ export const EDITOR_SET_VIZ_SCRIPT = 'EDITOR_SET_VIZ_SCRIPT' export const EDITOR_SET_TRANSFORM_SCRIPT = 'EDITOR_SET_TRANFORM_SCRIPT' export const EDITOR_UPDATE_BODY = 'EDITOR_UPDATE_BODY' export const EDITOR_REMOVE_SECTION = 'EDITOR_REMOVE_SECTION' +export const EDITOR_SET_SCHEMA = 'EDITOR_SET_SCHEMA' +export const EDITOR_SET_BODY_VIEW = 'EDITOR_SET_BODY_VIEW' +export const EDITOR_SET_COL_ORDER = 'EDITOR_SET_COL_ORDER' +export const EDITOR_SET_ROW_ORDER = 'EDITOR_SET_ROW_ORDER' +export const EDITOR_SET_BODY_ERROR = 'EDITOR_SET_BODY_ERROR' diff --git a/lib/containers/Editor.js b/lib/containers/Editor.js index 650556cc..88ca3f6f 100644 --- a/lib/containers/Editor.js +++ b/lib/containers/Editor.js @@ -18,9 +18,15 @@ import { setVizScript, setBody, updateBody, - removeSection + removeSection, + setSchema, + setBodyView, + setRowOrder, + setColOrder, + setBodyError } from '../actions/editor' -import { selectDryRunDatasetById } from '../selectors/dataset' + +import { selectDryRunDatasetById, extractColumnHeaders } from '../selectors/dataset' import { selectSessionProfileId, selectSessionProfileHandle } from '../selectors/session' import Editor from '../components/editor/Editor' @@ -30,7 +36,19 @@ const EditorContainer = connect( const id = selectSessionProfileId(state) const profilePeername = selectSessionProfileHandle(state) - const { name, dataset, transformScript, vizScript, body } = state.editor + const { + name, + dataset, + transformScript, + vizScript, + body, + bodyView, + showHeaders, + bodyError, + colOrder, + rowOrder + } = state.editor + return Object.assign({ profilePeername, id, @@ -40,6 +58,12 @@ const EditorContainer = connect( transformScript, vizScript, body, + showHeaders, + bodyError, + bodyView, + rowOrder, + colOrder, + columnHeaders: extractColumnHeaders({ dataset }), // localDataset: selectLocalDatasetByPath(state, 'new'), // TODO - finish @@ -59,6 +83,11 @@ const EditorContainer = connect( setBody, updateBody, removeSection, + setSchema, + setBodyView, + setRowOrder, + setColOrder, + setBodyError, dryRunDataset, saveDataset, diff --git a/lib/qri/generate.js b/lib/qri/generate.js new file mode 100644 index 00000000..5258e9c4 --- /dev/null +++ b/lib/qri/generate.js @@ -0,0 +1,191 @@ +import cloneDeep from 'clone-deep' + +export function generateSchemaFromRow (row) { + const items = row.map((e, i) => { return { 'title': 'col_' + (i + 1) } }) + const schema = { + type: 'array', + items: { + type: 'array', + items + } + } + return schema +} + +export function generateCollapsedChanges (body, columnHeaders, colOrder, rowOrder) { + if (!rowOrder || !Array.isArray(rowOrder) || !colOrder || !Array.isArray(colOrder) || !body || !columnHeaders) { + return + } + var rowMapped = rowOrder.map((position, index) => { return { index, position } }) + rowMapped.sort((a, b) => a.position - b.position) + var newBody = rowMapped.map(el => body[el.index]) + + var colMapped = colOrder.map((position, index) => { return { index, position } }) + colMapped.sort((a, b) => a.position - b.position) + + const newColumnHeaders = colMapped.map(el => columnHeaders[el.index]) + newBody = newBody.map(row => colMapped.map(el => row[el.index])) + + const newColOrder = colOrder.map((el, index) => index) + const newRowOrder = rowOrder.map((el, index) => index) + return { + body: newBody, + schema: { + type: 'array', + items: { + type: 'array', + items: newColumnHeaders + } + }, + rowOrder: newRowOrder, + colOrder: newColOrder + } +} + +// takes a body and structure and returns +// the most valid version of the parsed body & structure +// if the body or schema can't be parsed, it returns a string error +export function generateParsedBodyAndStructure (body, structure, colOrder, rowOrder) { + var validBody = body + var validStructure = structure + var schema = structure && structure.schema + + // will return either parsed schema, undefined, or string error + schema = generateParsedSchema(schema) + if (typeof schema === 'string') { + return schema + } + validStructure = Object.assign({}, structure, { schema }) + // time to check the body + // it body is a string, parse it! + if (typeof body === 'string') { + // if the body is empty, don't pass along a body + if (body === '') { + validBody = undefined + } else { + try { + validBody = JSON.parse(body) + } catch (e) { + return 'The dataset body is invalid.' + } + } + } else { + // otherwise, it is an object. The only case where the body would be an + // object, and stored in the dataset editor, is if it is a 2D array + // with a schema. Collapse any changes. If there are changes, pass + // them to the validBody and validStructure vars + const columnHeaders = schema && schema.items && schema.items.items + const changes = generateCollapsedChanges(validBody, columnHeaders, colOrder, rowOrder) + if (changes) { + validBody = changes.body + validStructure['schema'] = changes.schema + } + } + + return { body: validBody, structure: validStructure } +} + +// returns a parsed schema, undefined (if schema was false, undefined or ''), or a string error if the schema cannot be parsed +export function generateParsedSchema (schema) { + if (!schema) { + return undefined + } + if (typeof schema !== 'string') { + return schema + } + try { + const parsedSchema = JSON.parse(schema) + return parsedSchema + } catch (e) { + return 'The dataset schema is invalid.' + } +} + +// generateMatchingSchemaAndBody returns a schema and body that match. +// If we are in table view, we return a parsed Body and Schema (as well as +// fresh col and row orders) +// If we are in json view, we return a stringified Body, and leave the +// schema as is +// returns { schema, body, rowOrder, colOrder }, or a string error +export function generateMatchingSchemaAndBody (bodyView, structure, prevBody, columnHeaders, colOrder, rowOrder) { + var schema = structure && cloneDeep(structure.schema) + var body = cloneDeep(prevBody) + if (bodyView === 'table') { + // case where we have the body as a string, but need it to be + // an object + if (typeof prevBody === 'string') { + try { + body = JSON.parse(prevBody) + } catch (e) { + return "Body is invalid in it's current state." + } + } + // case where we have the body (which is an object) + // and we want to display in table view. We need to make sure + // it is the correct shape: + if (!Array.isArray(body)) { + return 'Table view is reserved for two dimentional data, the top level body must be an array.' + } + const row = body[0] + if (body.some(r => !Array.isArray(r) || r.length !== row.length)) { + return 'Table view is reserved for two dimentional data. Each element in the body must be an array and must be the same length.' + } + // From now one we know we have a 2D array! + // + // parse the schema, if its a string then it will return parsed (or as an error) + // if it is already parsed, it will just return + schema = generateParsedSchema(schema) + // + // if a string is returned from generateParsedSchema then it is + // actually an error string, just return it. + if (typeof schema === 'string') { + return schema + } + // + // from here on guarenteed a parsed or undefined schema! + if (schema) { + // We have a schema! + if (!schema.type || schema.type !== 'array' || !schema.items || schema.items.type !== 'array' || !schema.items.items || !Array.isArray(schema.items.items) || schema.items.items.length !== row.length) { + return 'The current schema does not match the structure of the body. If you remove the current schema, Qri will generate a basic schema for you' + } + // case where the body is a 2D array and we have schema that + // matches the body! + return { + schema, + colOrder: schema.items.items.map((elem, index) => index), + rowOrder: row.map((elem, index) => index), + body + } + } + // We have do not have a schema, so we must generate it! + schema = generateSchemaFromRow(row) + return { + schema, + colOrder: schema.items.items.map((elem, index) => index), + rowOrder: row.map((elem, index) => index), + body + } + } + // From here on in, we want the bodyView to be json: + // case where we want to have json view, and the body is already a string + // we don't have to make any changes + if (typeof body === 'string') { + return { body } + } + // case where we are trying to set view to json, and we have a + // non-string body + // we have to collapse the changes and stringify that (when we no longer use stringify) + const changed = generateCollapsedChanges(body, columnHeaders, colOrder, rowOrder) + if (changed) { + body = JSON.stringify(changed.body, null, 2) + return { + body, + schema: changed.schema, + colOrder: changed.colOrder, + rowOrder: changed.rowOrder + } + } + + body = JSON.stringify(body, null, 2) + return { body } +} diff --git a/lib/reducers/editor.js b/lib/reducers/editor.js index 7df35ef3..5af9f1b4 100644 --- a/lib/reducers/editor.js +++ b/lib/reducers/editor.js @@ -10,9 +10,18 @@ import { EDITOR_SET_VIZ_SCRIPT, EDITOR_SET_TRANSFORM, EDITOR_SET_TRANSFORM_SCRIPT, - EDITOR_REMOVE_SECTION + EDITOR_REMOVE_SECTION, + EDITOR_SET_SCHEMA, + EDITOR_SET_BODY_VIEW, + EDITOR_SET_COL_ORDER, + EDITOR_SET_ROW_ORDER, + EDITOR_SET_BODY_ERROR } from '../constants/editor' +import { + generateMatchingSchemaAndBody +} from '../qri/generate' + import cloneDeep from 'clone-deep' const initialState = { @@ -21,22 +30,67 @@ const initialState = { dataset: {}, vizScript: undefined, transformScript: undefined, - body: undefined + body: undefined, + colOrder: undefined, + rowOrder: undefined, + bodyView: 'json', + bodyError: '' } // TODO - TESTS! export default function editorReducer (state = initialState, action) { switch (action.type) { case EDITOR_INIT_DATASET: + var body = action.body + if (body) { + body = JSON.stringify(action.body, null, 2) + } return { dirty: false, name: action.name || '', dataset: action.dataset || {}, vizScript: action.vizScript, transformScript: action.transformScript, - body: action.body + bodyView: 'json', + body: body } - + case EDITOR_SET_BODY_VIEW: + var { body: prevBody, columnHeaders, colOrder, rowOrder, dirty } = state + var dataset = state.dataset + const check = generateMatchingSchemaAndBody(action.view, dataset.structure, prevBody, columnHeaders, colOrder, rowOrder) + if (typeof check === 'string') { + // if `check` is a string, then we have a problem switching to the other body view + // assign the error + return Object.assign({}, state, { bodyView: action.view, bodyError: check }) + } + // Otherwise, we might have to update other parts of our statetree + // to get the view to show correctly: + if (check.body) { + dirty = true + body = check.body + } + if (check.schema) { + dirty = true + var structure = Object.assign({}, dataset.structure, { schema: check.schema }) + dataset = Object.assign({}, dataset, { structure }) + } + if (check.colOrder) { + dirty = true + colOrder = check.colOrder + } + if (check.rowOrder) { + dirty = true + rowOrder = check.rowOrder + } + return Object.assign({}, state, { + dirty, + bodyView: action.view, + bodyError: '', + body, + dataset, + colOrder, + rowOrder + }) case EDITOR_SET_NAME: return Object.assign({}, state, { dirty: true, name: action.name }) case EDITOR_SET_COMMIT: @@ -48,8 +102,8 @@ export default function editorReducer (state = initialState, action) { dataset: Object.assign({}, state.dataset, { meta: action.meta }) }) case EDITOR_SET_STRUCTURE: - var dataset = state.dataset - var body = cloneDeep(state.body) + dataset = state.dataset + body = cloneDeep(state.body) const prevFormat = state && state.dataset && state.dataset.structure && state.dataset.structure.format const currFormat = action.structure && action.structure.format if (prevFormat !== currFormat) { @@ -64,6 +118,13 @@ export default function editorReducer (state = initialState, action) { dataset: Object.assign({}, state.dataset, { structure: action.structure }), body }) + case EDITOR_SET_SCHEMA: + dataset = state.dataset + structure = Object.assign({}, dataset.structure, { schema: action.schema }) + return Object.assign({}, state, { + dirty: true, + dataset: Object.assign({}, dataset, { structure }) + }) case EDITOR_SET_TRANSFORM: return Object.assign({}, state, { dirty: true, dataset: Object.assign({}, state.dataset, { transform: action.transform }) @@ -74,21 +135,39 @@ export default function editorReducer (state = initialState, action) { }) case EDITOR_SET_BODY: return Object.assign({}, state, { dirty: true, body: action.body }) + case EDITOR_SET_BODY_ERROR: + return Object.assign({}, state, { dirty: true, error: action.error }) case EDITOR_UPDATE_BODY: var newBody = cloneDeep(state.body) for (let [row, column, oldValue, newValue] of action.changes) { // eslint-disable-line no-unused-vars newBody[row][column] = newValue } - return Object.assign({}, state, { dirty: true, body: newBody }) + return Object.assign({}, state, { + dirty: true, + body: newBody + }) case EDITOR_SET_VIZ_SCRIPT: return Object.assign({}, state, { dirty: true, vizScript: action.vizScript }) case EDITOR_SET_TRANSFORM_SCRIPT: return Object.assign({}, state, { dirty: true, transformScript: action.transformScript }) case EDITOR_REMOVE_SECTION: - const datasetSections = Object.keys(state.dataset).filter(s => s !== action.section) + const datasetSections = Object.keys(state.dataset).filter(s => { + if (action.section === 'body') { + return s !== action.section && s !== 'bodyBytes' && s !== 'bodyPath' + } + return s !== action.section + }) + body = state.body + if (action.section === 'body') { + body = undefined + } dataset = {} datasetSections.forEach(s => { dataset[s] = state.dataset[s] }) - return Object.assign({}, state, { dirty: true, dataset: dataset }) + return Object.assign({}, state, { dirty: true, dataset, body }) + case EDITOR_SET_COL_ORDER: + return Object.assign({}, state, { dirty: true, colOrder: action.order }) + case EDITOR_SET_ROW_ORDER: + return Object.assign({}, state, { dirty: true, rowOrder: action.order }) default: return state } diff --git a/lib/scss/_handsontable.scss b/lib/scss/_handsontable.scss new file mode 100644 index 00000000..5e026e75 --- /dev/null +++ b/lib/scss/_handsontable.scss @@ -0,0 +1,14 @@ +// styling for the HandsOnTableEditor + +.editor.handsontable th { + color: $primary-light; +} + +// for some reason this specific left side border wants to be a weird color +// this random css fixes it so it looks like every other border +.editor.handsontable.htRowHeaders thead tr th:nth-child(2) { + border-left: 1px solid #1e1e1e; + border-left-width: 1px; + border-left-style: solid; + border-left-color: rgb(30, 30, 30); +} \ No newline at end of file diff --git a/lib/scss/style.scss b/lib/scss/style.scss index 007377d8..8cda8361 100755 --- a/lib/scss/style.scss +++ b/lib/scss/style.scss @@ -10,4 +10,6 @@ @import "dropdown"; @import "hotable"; -@import "site"; \ No newline at end of file +@import "site"; + +@import "handsontable"; \ No newline at end of file diff --git a/lib/selectors/dataset.js b/lib/selectors/dataset.js index b6643b73..06fe0f19 100755 --- a/lib/selectors/dataset.js +++ b/lib/selectors/dataset.js @@ -136,27 +136,30 @@ export function selectNoDatasetBody (state, section, node) { selectFetchedAll(state, section, node) === true && (data === [] || data === {}) } -// extractSchema should be used when trying to get the schema -// it will extract the column names and details for structured data -// or the full schema for unstructured data -export function extractSchema (datasetRef = {}) { +// extractColumnHeaders should be used when trying to get the column header information +// from the schema +export function extractColumnHeaders (datasetRef = {}) { var structure = datasetRef.dataset && datasetRef.dataset.structure if (!structure) { return undefined } - var format = structure.format - if (!format) { + if (structure.schema && (!structure.schema.items || (structure.schema.items && !structure.schema.items.items))) { return undefined } - if (format !== 'csv') { - return structure.schema - } - return structure.schema && structure.schema.items && structure.schema.items.items } +// extractSchema should be used when trying to extract the whole schema +export function extractSchema (datasetRef = {}) { + var structure = datasetRef.dataset && datasetRef.dataset.structure + if (!structure) { + return undefined + } + + return structure.schema +} export function isLocalDataset (state, path) { const sessionID = selectSessionProfileId(state) const datasetIds = state && state.pagination && state.pagination[usersDatasetsSection] && state.pagination[usersDatasetsSection][sessionID] && state.pagination[usersDatasetsSection][sessionID].ids diff --git a/package.json b/package.json index 81b50589..ddb9616e 100755 --- a/package.json +++ b/package.json @@ -183,7 +183,7 @@ "file-loader": "2.0.0", "file-saver": "1.3.8", "global": "4.3.2", - "handsontable": "5.0.2", + "handsontable": "6.2.2", "history": "4.7.2", "html-webpack-plugin": "3.2.0", "identity-obj-proxy": "3.0.0", diff --git a/yarn.lock b/yarn.lock index 27b9a186..1a5514e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4834,9 +4834,10 @@ handlebars@^4.0.3: optionalDependencies: uglify-js "^3.1.4" -handsontable@5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/handsontable/-/handsontable-5.0.2.tgz#dcfb4341afe64c98bbbf779be40f69721e3e2c2c" +handsontable@6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/handsontable/-/handsontable-6.2.2.tgz#f1250f3f374abdf7d4a0080950482d3edeea8f07" + integrity sha512-Z/sQa51OMHH4RoeBJeANYJMJYmx5SR+/xP8JCh5mzKJnAMKoQWF1zONPNgNCFZ/LdKFmI0f34XKtU0GHW0MG/Q== dependencies: moment "2.20.1" numbro "^2.0.6"