diff --git a/config/partials/entryLocal.js b/config/partials/entryLocal.js new file mode 100644 index 0000000..361703a --- /dev/null +++ b/config/partials/entryLocal.js @@ -0,0 +1,12 @@ +'use strict'; + +var partial = require('webpack-partial').default; + +module.exports = function (config) { + return partial(config, { + entry: {bundle: [ + 'whatwg-fetch', + './local.js' + ]} + }); +}; diff --git a/config/partials/outputLocal.js b/config/partials/outputLocal.js new file mode 100644 index 0000000..a07bf56 --- /dev/null +++ b/config/partials/outputLocal.js @@ -0,0 +1,18 @@ +'use strict'; + +var path = require('path'); +var partial = require('webpack-partial').default; + +var ROOT = process.cwd(); +var BUILD = path.join(ROOT, 'dash_renderer'); + +module.exports = function (config) { + return partial(config, { + output: { + path: BUILD, + publicPath: '/dash_renderer/', + // TODO: Bundle filename should be hashed (#10) + filename: 'local.js' + } + }); +}; diff --git a/config/webpack.config.local.js b/config/webpack.config.local.js new file mode 100644 index 0000000..77ffe40 --- /dev/null +++ b/config/webpack.config.local.js @@ -0,0 +1,17 @@ +'use strict'; + +var compose = require('ramda').compose; + +var babel = require('./partials/babel'); +var defineEnv = require('./partials/defineEnv'); +var entryLocal = require('./partials/entryLocal'); +var outputLocal = require('./partials/outputLocal'); +var baseConfig = require('./webpack.config'); + +// TODO: support locally served source maps in production (#11) +module.exports = compose( + babel, + defineEnv, + entryLocal, + outputLocal +)(baseConfig); diff --git a/index.html b/index.html index 5da1975..d072788 100644 --- a/index.html +++ b/index.html @@ -3,13 +3,19 @@ - +
- + - + + - + + + + + diff --git a/package.json b/package.json index 6b33ef0..2c0a2ee 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build-dev": "./node_modules/.bin/webpack --config=config/webpack.config.dev.js", "build-prod": "cross-env NODE_ENV=production node node_modules/.bin/webpack --config=config/webpack.config.prod.js", + "build-local": "cross-env NODE_ENV=production node node_modules/.bin/webpack --config=config/webpack.config.local.js", "dev": "./node_modules/.bin/webpack-dev-server --config=config/webpack.config.dev.js", "hot": "./node_modules/.bin/webpack-dev-server --hot --config=config/webpack.config.hot.js", "lint": "./node_modules/.bin/eslint --quiet --fix .", diff --git a/src/actions/index.js b/src/actions/index.js index bec3ada..b6e338c 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -185,7 +185,7 @@ function loadSavedStateAndTriggerVisibleInputs(dispatch, getState, skipTheseInpu }; } -function triggerDefaultState(dispatch, getState) { +export function triggerDefaultState(dispatch, getState) { const {graphs} = getState(); const {InputGraph} = graphs; const allNodes = InputGraph.overallOrder(); @@ -448,7 +448,6 @@ export const notifyObservers = function(payload) { } - promises.push(fetch('/update-component', { method: 'POST', headers: { @@ -464,7 +463,6 @@ export const notifyObservers = function(payload) { }); return res.json().then(function handleJson(data) { - // clear this item from the request queue dispatch(setRequestQueue( reject( diff --git a/src/local.js b/src/local.js new file mode 100644 index 0000000..9ecde38 --- /dev/null +++ b/src/local.js @@ -0,0 +1,185 @@ +/*eslint-env browser */ +import React from 'react'; +import ReactDOM from 'react-dom'; +require('es6-promise').polyfill(); +import {combineReducers} from 'redux'; +import {connect, Provider} from 'react-redux' +import {createStore, applyMiddleware} from 'redux'; +import thunk from 'redux-thunk'; +import createLogger from 'redux-logger'; +import {pluck, reduce} from 'ramda'; + +// Actions +import { + computeGraphs, + computePaths, + setLayout, + triggerDefaultState +} from './actions/index'; + +// Render the app +import renderTree from './renderTree'; + +// Stores +import layout from './reducers/layout'; +import graphs from './reducers/dependencyGraph'; +import paths from './reducers/paths'; +import requestQueue from './reducers/requestQueue'; +import * as API from './reducers/api'; + + + + + + + + + + + + + + + +/* + * Change the path here to try out different examples. + * Examples need to export appLayout and mapInputsToOutputs + * objects. +*/ +import {appLayout, mapInputsToOutputs} from './local_examples/example_1_input_to_div'; + + + + + + + + + + + + + + + + + + + + + + + + + + + + +/* + * You shouldn't have to edit anything below here + * This block below does a few things: + * - it transforms the API calls into + * some promises that call the `transform` method + * for the appropriate input-output pair. + * - it initializes the app without the API controller + */ + +const dependenciesRequest = { + content: mapInputsToOutputs.map(object => { + object.events = []; + object.state = []; + return object; + }), + status: 200 +}; + +window.fetch = function(url, options) { + const payload = JSON.parse(options.body); + return new Promise(resolveResonse => { + const inputOutputPair = mapInputsToOutputs.find(pair => + (pair.output.id === payload.output.id) && + (pair.output.property === payload.output.property) + ); + resolveResonse({ + status: 200, + json: () => new Promise(resolveJson => { + resolveJson({ + response: { + props: { + 'id': inputOutputPair.output.id, + [inputOutputPair.output.property]: ( + inputOutputPair.output.transform( + reduce( + (acc, input) => { + acc[`${input.id}.${input.property}`] = input.value; + return acc + }, + {}, + payload.inputs + ) + ) + ) + } + } + }) + }) + }) + }); +} + + +// Initialize a store +const reducer = combineReducers({ + layout, + graphs, + paths, + requestQueue, + dependenciesRequest: API.dependenciesRequest +}); +const logger = createLogger() +let store; +const initializeStore = () => { + if (store) { + return store; + } + + store = createStore( + reducer, + applyMiddleware(thunk, logger) + ); + + window.store = store; /* global window:true */ + + return store; +}; + +store = initializeStore(); + +store.dispatch({ + type: 'dependenciesRequest', + payload: dependenciesRequest +}); +store.dispatch(setLayout(appLayout)); +store.dispatch(computePaths({subTree: appLayout, startingPath: []})); +store.dispatch(computeGraphs(dependenciesRequest.content)); +store.dispatch(triggerDefaultState); + +const ConnectedApp = connect(state => ({ + layout: state.layout, + dependenciesRequest: state.dependenciesRequest +}))( + props => { + return ( +
+ {renderTree(props.layout, props.dependenciesRequest.content)} +
+ ) + } +); + +ReactDOM.render( + + + , + document.getElementById('react-entry-point') +); diff --git a/src/local_examples/example_1_input_to_div.js b/src/local_examples/example_1_input_to_div.js new file mode 100644 index 0000000..a4d4de1 --- /dev/null +++ b/src/local_examples/example_1_input_to_div.js @@ -0,0 +1,51 @@ +export const appLayout = { + 'type': 'Div', + 'namespace': 'dash_html_components', + 'props': { + 'content': [ + + { + 'type': 'Input', + 'namespace': 'dash_core_components', + 'props': { + 'id': 'my-input', + 'value': 'Initial value' + } + }, + + { + 'type': 'Div', + 'namespace': 'dash_html_components', + 'props': { + 'id': 'my-div' + } + } + + ] + } +}; + +export const mapInputsToOutputs = [ + { + inputs: [ + { + id: 'my-input', + property: 'value' + } + ], + output: { + id: 'my-div', + property: 'content', + + /* + * Of course, we can't have functions in the actual spec, + * but we could introduce a lightweight transformation + * language for modifying strings, accessing values in + * objects or arrays, arithmetic, etc. + */ + transform: inputArguments => { + return `You've entered: ${inputArguments['my-input.value']}`; + } + } + } +]; diff --git a/src/local_examples/example_2_register_components.js b/src/local_examples/example_2_register_components.js new file mode 100644 index 0000000..fbbfc6e --- /dev/null +++ b/src/local_examples/example_2_register_components.js @@ -0,0 +1,323 @@ +import R from 'ramda'; + +import * as components from './example_components.react'; + +export const appLayout = { + 'type': 'Div', + 'namespace': 'dash_html_components', + 'props': { + 'content': [ + + { + 'type': 'DataStore', + 'namespace': 'dash_functional_components', + 'props': { + 'id': 'my-data', + 'columns': { + 'Column 1': [1, 2, 3, 4, 5], + 'Column 2': [4, 2, 1, 4, 5], + 'Column 3': ['A', 'B', 'C' ,'D', 'E'], + } + } + }, + + { + 'type': 'Filter', + 'namespace': 'dash_functional_components', + 'props': { + 'id': 'my-filter', + /* + * The rest of the filter properties + * (like filterColumnId, filterValue, operation) + * are set dynamically from different controls + */ + } + }, + + { + 'type': 'Dropdown', + 'namespace': 'dash_core_components', + 'props': { + 'id': 'filter-column-dropdown', + /* + * The rest of these properties like `options` and `value` + * are set dynamically from the DataStore component + */ + } + }, + + { + 'type': 'Dropdown', + 'namespace': 'dash_core_components', + 'props': { + 'id': 'filter-operation-dropdown', + 'options': [ + {'label': 'Less than', 'value': '<'}, + {'label': 'Greater than', 'value': '>'}, + {'label': 'Equals', 'value': '=='} + ], + 'value': '<' + } + }, + + { + 'type': 'Input', + 'namespace': 'dash_core_components', + 'props': { + 'id': 'my-input', + 'value': 3 + } + }, + + { + 'type': 'Div', + 'namespace': 'dash_html_components', + 'props': { + 'className': 'row', + 'content': [ + { + 'type': 'Div', + 'namespace': 'dash_html_components', + 'props': { + 'className': 'six columns', + 'content': { + 'type': 'Table', + 'namespace': 'dash_functional_components', + 'props': { + 'id': 'my-original-table' + } + } + } + }, + { + 'type': 'Div', + 'namespace': 'dash_html_components', + 'props': { + 'className': 'six columns', + 'content': { + 'type': 'Table', + 'namespace': 'dash_functional_components', + 'props': { + 'id': 'my-filtered-table' + } + } + } + } + ] + } + }, + + { + 'type': 'Graph', + 'namespace': 'dash_core_components', + 'props': { + 'id': 'my-graph', + 'animate': true + } + } + + ] + } +}; + +export const mapInputsToOutputs = [ + + // Display the columns as the dropdown + { + inputs: [ + { + id: 'my-data', + property: 'columns' + }, + ], + output: { + id: 'filter-column-dropdown', + property: 'options', + + /* + * The properties of the components would need to be designed + * in a way that makes composing one components output into + * the next component's input declarative. + * In this case, we're setting the dropdown to be the columns + * through `keys` and `map` functions, which isn't declarative + * nor does it have a direct declarative analogue. + */ + transform: inputArguments => ( + R.keys(inputArguments['my-data.columns']).map(c => ({ + label: c, value: c + })) + ) + } + }, + + // Select a default column + { + inputs: [ + { + id: 'my-data', + property: 'columns' + }, + ], + output: { + id: 'filter-column-dropdown', + property: 'value', + + /* + * The properties of the components would need to be designed + * in a way that makes composing one components output into + * the next component's input declarative. + * In this case, we're setting the dropdown to be the columns + * through `keys` and `map` functions, which isn't declarative + * nor does it have a direct declarative analogue. + */ + transform: inputArguments => ( + R.keys(inputArguments['my-data.columns'])[0] + ) + } + }, + + // The `filterColumnId` of the filter is the dropdown + { + inputs: [ + { + id: 'filter-operation-dropdown', + property: 'value' + }, + ], + output: { + id: 'my-filter', + property: 'operation', + + transform: inputArguments => inputArguments['filter-operation-dropdown.value'] + } + }, + + // The input data (`columns`) for the filter is the data store + { + inputs: [ + { + id: 'my-data', + property: 'columns' + }, + ], + output: { + id: 'my-filter', + property: 'columns', + + transform: inputArguments => inputArguments['my-data.columns'] + } + }, + + // The `filterValue` of the filter is the input's value + { + inputs: [ + { + id: 'my-input', + property: 'value' + }, + ], + output: { + id: 'my-filter', + property: 'filterValue', + + transform: inputArguments => inputArguments['my-input.value'] + } + }, + + // The `filterColumnId` of the filter is the dropdown + { + inputs: [ + { + id: 'filter-column-dropdown', + property: 'value' + }, + ], + output: { + id: 'my-filter', + property: 'filterColumnId', + + transform: inputArguments => inputArguments['filter-column-dropdown.value'] + } + }, + + // Display the original data as a table + { + inputs: [ + { + id: 'my-data', + property: 'columns' + }, + ], + output: { + id: 'my-original-table', + property: 'columns', + + transform: inputArguments => inputArguments['my-data.columns'] + } + }, + + // Display the output of the filter as a table + { + inputs: [ + { + id: 'my-filter', + property: 'filteredColumns' + }, + ], + output: { + id: 'my-filtered-table', + property: 'columns', + + transform: inputArguments => inputArguments['my-filter.filteredColumns'] + } + }, + + // Combine the filtered data and the original data on a graph + { + inputs: [ + { + id: 'my-filter', + property: 'filteredColumns' + }, + { + id: 'my-data', + property: 'columns' + } + ], + + output: { + id: 'my-graph', + property: 'figure', + + transform: inputArguments => ({ + 'layout': {'showlegend': false, 'margin': {'l': 10, 'r': 10, 't': 10}}, + 'data': [ + { + 'x': inputArguments['my-data.columns']['Column 1'], + 'y': inputArguments['my-data.columns']['Column 2'], + 'text': inputArguments['my-data.columns']['Column 3'], + 'mode': 'markers', + 'opacity': 0.2, + 'marker': { + 'size': 24 + } + }, + { + 'x': inputArguments['my-filter.filteredColumns']['Column 1'], + 'y': inputArguments['my-filter.filteredColumns']['Column 2'], + 'text': inputArguments['my-filter.filteredColumns']['Column 3'], + 'mode': 'markers', + 'marker': { + 'size': 14, + 'line': { + 'width': 0.5, + 'color': 'lightgrey' + } + } + } + ] + }) + } + } + +]; diff --git a/src/local_examples/example_3_crossfilter.js b/src/local_examples/example_3_crossfilter.js new file mode 100644 index 0000000..58408ee --- /dev/null +++ b/src/local_examples/example_3_crossfilter.js @@ -0,0 +1,299 @@ +import R from 'ramda'; + +import * as components from './example_components.react'; + +export const appLayout = { + 'type': 'Div', + 'namespace': 'dash_html_components', + 'props': { + 'content': [ + + { + 'type': 'DataStore', + 'namespace': 'dash_functional_components', + 'props': { + 'id': 'my-data', + 'columns': { + 'Index': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + 'Column 1': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + 'Column 2': [1, 2, 2, 3, 3, 3, 4, 4, 4, 4], + 'Column 3': ['A', 'B', 'C', 'D', 'E', + 'A', 'A', 'C', 'C', 'E'], + 'Column 4': [2, 1, 4, 5, 1, 3, 2, 5, 6, 9] + } + } + }, + + { + 'type': 'Filter', + 'namespace': 'dash_functional_components', + 'props': { + 'id': 'filter-graph-1-vs-2', + 'operation': 'intersect', + 'filterColumnId': 'Index' + } + }, + + { + 'type': 'Filter', + 'namespace': 'dash_functional_components', + 'props': { + 'id': 'filter-graph-2-vs-4', + 'operation': 'intersect', + 'filterColumnId': 'Index' + } + }, + + { + 'type': 'Div', + 'namespace': 'dash_html_components', + 'props': { + 'content': 'Hover or drag points on either graph' + } + }, + + { + 'type': 'Div', + 'namespace': 'dash_html_components', + 'props': { + 'className': 'row', + 'content': [ + { + 'type': 'Div', + 'namespace': 'dash_html_components', + 'props': { + 'className': 'six columns', + 'content': { + 'type': 'Graph', + 'namespace': 'dash_core_components', + 'props': { + 'id': 'graph-1-vs-2' + } + } + } + }, + { + 'type': 'Div', + 'namespace': 'dash_html_components', + 'props': { + 'className': 'six columns', + 'content': { + 'type': 'Graph', + 'namespace': 'dash_core_components', + 'props': { + 'id': 'graph-2-vs-4' + } + } + } + } + ] + } + }, + + { + 'type': 'Table', + 'namespace': 'dash_functional_components', + 'props': { + 'id': 'table-all-data' + } + } + + ] + } +}; + + +export const mapInputsToOutputs = [ + + { + inputs: [ + { + 'id': 'my-data', + 'property': 'columns' + } + ], + output: { + 'id': 'table-all-data', + 'property': 'columns', + 'transform': inputArguments => inputArguments['my-data.columns'] + } + }, + + { + inputs: [ + { + 'id': 'my-data', + 'property': 'columns' + } + ], + output: { + 'id': 'filter-graph-1-vs-2', + 'property': 'columns', + 'transform': inputArguments => inputArguments['my-data.columns'] + } + }, + + { + inputs: [ + { + 'id': 'my-data', + 'property': 'columns' + } + ], + output: { + 'id': 'filter-graph-2-vs-4', + 'property': 'columns', + 'transform': inputArguments => inputArguments['my-data.columns'] + } + }, + + { + inputs: [ + { + 'id': 'graph-1-vs-2', + 'property': 'hoverData', + }, + { + 'id': 'graph-1-vs-2', + 'property': 'selectedData', + } + ], + output: { + 'id': 'filter-graph-1-vs-2', + 'property': 'filterValue', + 'transform': inputArguments => { + let hoverData = []; + let selectedData = []; + if (inputArguments['graph-1-vs-2.hoverData']) { + hoverData = R.pluck('customdata', inputArguments['graph-1-vs-2.hoverData']['points']); + } + if (inputArguments['graph-1-vs-2.selectedData']) { + selectedData = R.pluck('customdata', inputArguments['graph-1-vs-2.selectedData']['points']); + } + return R.concat(hoverData, selectedData); + } + } + }, + + { + inputs: [ + { + 'id': 'graph-2-vs-4', + 'property': 'hoverData', + }, + { + 'id': 'graph-2-vs-4', + 'property': 'selectedData', + } + ], + output: { + 'id': 'filter-graph-2-vs-4', + 'property': 'filterValue', + 'transform': inputArguments => { + let hoverData = []; + let selectedData = []; + if (inputArguments['graph-2-vs-4.hoverData']) { + hoverData = R.pluck('customdata', inputArguments['graph-2-vs-4.hoverData']['points']); + } + if (inputArguments['graph-2-vs-4.selectedData']) { + selectedData = R.pluck('customdata', inputArguments['graph-2-vs-4.selectedData']['points']); + } + return R.concat(hoverData, selectedData); + } + } + }, + + { + inputs: [ + { + 'id': 'my-data', + 'property': 'columns' + }, + { + 'id': 'filter-graph-2-vs-4', + 'property': 'filteredColumns' + } + ], + output: { + 'id': 'graph-1-vs-2', + 'property': 'figure', + + 'transform': inputArguments => ({ + 'layout': { + 'margin': {'l': 20, 'r': 10, 't': 10, 'b': 20}, + 'hovermode': 'closest', + 'dragmode': 'select', + 'showlegend': false + }, + 'data': [{ + 'x': inputArguments['my-data.columns']['Column 1'], + 'y': inputArguments['my-data.columns']['Column 2'], + 'text': inputArguments['my-data.columns']['Column 3'], + 'customdata': inputArguments['my-data.columns']['Index'], + 'mode': 'markers', + 'opacity': 0.2, + 'marker': { + 'size': 20 + } + }, { + 'x': inputArguments['filter-graph-2-vs-4.filteredColumns']['Column 1'], + 'y': inputArguments['filter-graph-2-vs-4.filteredColumns']['Column 2'], + 'text': inputArguments['filter-graph-2-vs-4.filteredColumns']['Column 3'], + 'customdata': inputArguments['filter-graph-2-vs-4.filteredColumns']['Index'], + 'mode': 'markers', + 'marker': { + 'size': 10 + } + }] + }) + } + }, + + { + inputs: [ + { + 'id': 'my-data', + 'property': 'columns' + }, + { + 'id': 'filter-graph-1-vs-2', + 'property': 'filteredColumns' + } + + ], + output: { + 'id': 'graph-2-vs-4', + 'property': 'figure', + + 'transform': inputArguments => ({ + 'layout': { + 'margin': {'l': 20, 'r': 10, 't': 10, 'b': 20}, + 'hovermode': 'closest', + 'dragmode': 'select', + 'showlegend': false + }, + 'data': [{ + 'x': inputArguments['my-data.columns']['Column 2'], + 'y': inputArguments['my-data.columns']['Column 4'], + 'text': inputArguments['my-data.columns']['Column 3'], + 'customdata': inputArguments['my-data.columns']['Index'], + 'mode': 'markers', + 'opacity': 0.2, + 'marker': { + 'size': 20 + } + }, { + 'x': inputArguments['filter-graph-1-vs-2.filteredColumns']['Column 2'], + 'y': inputArguments['filter-graph-1-vs-2.filteredColumns']['Column 4'], + 'text': inputArguments['filter-graph-1-vs-2.filteredColumns']['Column 3'], + 'customdata': inputArguments['filter-graph-1-vs-2.filteredColumns']['Index'], + 'mode': 'markers', + 'marker': { + 'size': 10 + } + }] + }) + } + } + +] diff --git a/src/local_examples/example_components.react.js b/src/local_examples/example_components.react.js new file mode 100644 index 0000000..c9d8af5 --- /dev/null +++ b/src/local_examples/example_components.react.js @@ -0,0 +1,99 @@ +import R from 'ramda'; +import React, {PropTypes, Component} from 'react'; + +export class Filter extends Component { + componentWillReceiveProps(nextProps) { + if (['columns', 'filterColumnId', 'filterValue', 'operation'].find( + prop => nextProps[prop] !== this.props[prop] + )) { + const { + columns, filterColumnId, operation, filterValue, setProps + } = nextProps; + + console.warn('nextProps: ', nextProps); + + let filteredColumns; + if (columns) { + filteredColumns = R.map(() => [], columns); + } + + if (!(filterColumnId && operation && filterValue)) { + if (columns) { + setProps({filteredColumns}); + } + return; + } + + const pushRow = i => R.keys(filteredColumns).forEach(colId => + filteredColumns[colId].push(columns[colId][i]) + ); + + columns[filterColumnId].forEach((cell, i) => { + if ((operation === '<' && cell < parseFloat(filterValue, 10)) || + (operation === '>' && cell > parseFloat(filterValue, 10)) || + (operation === '==' && cell == filterValue) || + (operation === 'intersect' && R.contains(cell, filterValue)) + ) { + pushRow(i); + } + }); + console.warn('filteredColumns: ', filteredColumns); + setProps({filteredColumns}); + } + } + + render() { + return null; + } +} +Filter.propTypes = { + /* + * `id` and `setProps` are required by dash + */ + id: PropTypes.string.isRequired, + setProps: PropTypes.func.isRequired, + + /* + * The rest of the properties are the + * declarative properties of the filter + * transform + */ + columns: PropTypes.object, + filterColumnId: PropTypes.string, + operation: PropTypes.oneOf(['<', '>', '==', 'intersect']), + filterValue: PropTypes.oneOfType( + PropTypes.array, + PropTypes.number, + PropTypes.string + ), + + /* + * This set of "props" are output + * props and are computed by the set of + * input props + */ + filteredColumns: PropTypes.object +} + +export const DataStore = props => null; + +export const Table = props => { + if (!props.columns) return null; + const firstColumn = props.columns[R.keys(props.columns)[0]]; + const rows = []; + firstColumn.forEach((_, i) => (rows.push( + + {R.values(props.columns).map(col => {col[i]})} + + ))); + return ( + + {R.keys(props.columns).map(colId => )} + {rows} +
{colId}
+ ); +} + +window.dash_functional_components = { + DataStore, Filter, Table +};