diff --git a/.eslintrc b/.eslintrc
index 21663ff..c038cae 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -11,6 +11,9 @@
"env": {
"browser": true
},
+ "plugins": [
+ "react"
+ ],
"rules": {
"no-console": 0,
"semi": 2,
@@ -72,6 +75,30 @@
{
"max": 4
}
- ] // max depth of nesting in general
+ ], // max depth of nesting in general
+ // React rules
+ "react/jsx-uses-react": 2,
+ "react/react-in-jsx-scope": 2,
+ "react/require-render-return": 2,
+ "react/no-unknown-property": 2,
+ "react/prefer-stateless-function": 1,
+ "react/wrap-multilines": 2,
+ "react/no-danger": 2,
+ "react/jsx-uses-vars": 2,
+ "react/jsx-no-undef": 2,
+ "react/jsx-no-duplicate-props": 2,
+ "react/no-direct-mutation-state": 2,
+ "react/no-did-update-set-state": 2, // use componentWillUpdate instead
+ "react/no-did-mount-set-state": 2, // use componentWillMount instead
+ "react/no-deprecated": 2,
+ "react/prop-types": 2,
+ "react/jsx-no-bind": 1, // refactoring to prototype functions is more efficient
+ "react/jsx-indent": [2, "tab"],
+ "react/jsx-indent-props": [2, "tab"],
+ "react/jsx-max-props-per-line": [2, { "maximum": 2 }],
+ "react/jsx-equals-spacing": [2, "never"],
+ "react/jsx-space-before-closing": 2,
+ "react/jsx-boolean-value": [2, "never"],
+ "react/no-comment-textnodes": 2
}
}
\ No newline at end of file
diff --git a/build b/build
index 32ae0fc..2fc8e21 100755
--- a/build
+++ b/build
@@ -12,12 +12,12 @@ set -o errexit
mkdir ./python/static/js
# Build static assets
-echo "Building static assets."
-uglifycss ./client/css/* > ./python/static/css/bundle.min.css
echo "Minifying JS bundle and inserting it into HTML template"
webpack
+echo "Building static assets."
+uglifycss ./client/css/* > ./python/static/css/bundle.min.css
# I can't figure out how to both generate correct paths in the HTML template
# of webpack, and have the resulting index.html be put in the right folders.
# Yes, it's an ugly hack, but it works - Job
-mv python/index.html python/static/index.html
+mv ./python/index.html ./python/static/index.html
\ No newline at end of file
diff --git a/client/actions/actionTypes.js b/client/actions/actionTypes.js
new file mode 100644
index 0000000..0f0ba9a
--- /dev/null
+++ b/client/actions/actionTypes.js
@@ -0,0 +1,17 @@
+export const REQUEST_PROJECTS = 'REQUEST_PROJECTS';
+export const REQUEST_PROJECTS_FAILED = 'REQUEST_PROJECTS_FAILED';
+export const RECEIVE_PROJECTS = 'RECEIVE_PROJECTS';
+
+export const REQUEST_DATASET = 'REQUEST_DATASET';
+export const REQUEST_DATASET_FAILED = 'REQUEST_DATASET_FAILED';
+export const RECEIVE_DATASET = 'RECEIVE_DATASET';
+
+export const REQUEST_GENE = 'REQUEST_GENE';
+export const REQUEST_GENE_FAILED = 'REQUEST_GENE_FAILED';
+export const RECEIVE_GENE = 'RECEIVE_GENE';
+
+export const SET_HEATMAP_PROPS = 'SET_HEATMAP_PROPS';
+export const SET_GENESCAPE_PROPS = 'SET_GENESCAPE_PROPS';
+export const SET_LANDSCAPE_PROPS = 'SET_LANDSCAPE_PROPS';
+export const SET_SPARKLINE_PROPS = 'SET_SPARKLINE_PROPS';
+export const SET_VIEW_PROPS = 'SET_VIEW_PROPS';
\ No newline at end of file
diff --git a/client/actions/actions.js b/client/actions/actions.js
index f3f8d75..fb2e592 100644
--- a/client/actions/actions.js
+++ b/client/actions/actions.js
@@ -1,5 +1,18 @@
import 'whatwg-fetch';
-import * as _ from 'lodash';
+
+import {
+ REQUEST_PROJECTS,
+ REQUEST_PROJECTS_FAILED,
+ RECEIVE_PROJECTS,
+ REQUEST_DATASET,
+ REQUEST_DATASET_FAILED,
+ RECEIVE_DATASET,
+ REQUEST_GENE,
+ REQUEST_GENE_FAILED,
+ RECEIVE_GENE,
+} from './actionTypes';
+
+import { groupBy } from 'lodash';
///////////////////////////////////////////////////////////////////////////////////////////
@@ -11,19 +24,19 @@ import * as _ from 'lodash';
function requestProjects() {
return {
- type: 'REQUEST_PROJECTS',
+ type: REQUEST_PROJECTS,
};
}
function requestProjectsFailed() {
return {
- type: 'REQUEST_PROJECTS_FAILED',
+ type: REQUEST_PROJECTS_FAILED,
};
}
function receiveProjects(projects) {
return {
- type: 'RECEIVE_PROJECTS',
+ type: RECEIVE_PROJECTS,
projects: projects,
};
}
@@ -32,51 +45,59 @@ function receiveProjects(projects) {
// Though its insides are different, you would use it just like any other action creator:
// store.dispatch(fetchgene(...))
-export function fetchProjects() {
+export function fetchProjects(projects) {
return (dispatch) => {
// First, make known the fact that the request has been started
dispatch(requestProjects());
- // Second, perform the request (async)
- return fetch(`/loom`)
- .then((response) => { return response.json();})
- .then((json) => {
- // Third, once the response comes in, dispatch an action to provide the data
- // Group by project
- const projs = _.groupBy(json, (item) => { return item.project; });
- dispatch(receiveProjects(projs));
- })
- // Or, if it failed, dispatch an action to set the error flag
- .catch((err) => {
- console.log(err);
- dispatch(requestProjectsFailed());
- });
+ // Second, check if projects already exists in the store.
+ // If not, perform a fetch request (async)
+ if (projects === undefined) {
+ return (
+ fetch(`/loom`)
+ .then((response) => { return response.json(); })
+ .then((json) => {
+ // Grouping by project must be done here, instead of in
+ // the reducer, because if it is already in the store we
+ // want to pass it back unmodified (see else branch below)
+ const fetchedProjects = groupBy(json, (item) => { return item.project; });
+ dispatch(receiveProjects(fetchedProjects));
+ })
+ // Or, if it failed, dispatch an action to set the error flag
+ .catch((err) => {
+ console.log(err);
+ dispatch(requestProjectsFailed());
+ })
+ );
+ } else {
+ return dispatch(receiveProjects(projects));
+ }
};
}
///////////////////////////////////////////////////////////////////////////////////////////
//
-// Fetch metadata for a dataset
+// Fetch metadata for a dataSet
//
///////////////////////////////////////////////////////////////////////////////////////////
-function requestDataset(dataset) {
+function requestDataSet(dataSet) {
return {
- type: 'REQUEST_DATASET',
- dataset: dataset,
+ type: REQUEST_DATASET,
+ dataSet: dataSet,
};
}
-function requestDatasetFailed() {
+function requestDataSetFailed() {
return {
- type: 'REQUEST_DATASET_FAILED',
+ type: REQUEST_DATASET_FAILED,
};
}
-function receiveDataset(dataset) {
+function receiveDataSet(receivedDataSet) {
return {
- type: 'RECEIVE_DATASET',
- dataset: dataset,
+ type: RECEIVE_DATASET,
+ receivedDataSet,
};
}
@@ -84,28 +105,39 @@ function receiveDataset(dataset) {
// Though its insides are different, you would use it just like any other action creator:
// store.dispatch(fetchgene(...))
-export function fetchDataset(dataset) {
+export function fetchDataSet(data) {
+ const { dataSetName, dataSets } = data;
return (dispatch) => {
// First, make known the fact that the request has been started
- dispatch(requestDataset(dataset));
- // Second, perform the request (async)
- return fetch(`/loom/${dataset}/fileinfo.json`)
- .then((response) => { return response.json(); })
- .then((ds) => {
- // Third, once the response comes in, dispatch an action to provide the data
- // Also, dispatch some actions to set required properties on the subviews
- const ra = ds.rowAttrs[0];
- const ca = ds.colAttrs[0];
- dispatch({ type: 'SET_GENESCAPE_PROPS', xCoordinate: ra, yCoordinate: ra, colorAttr: ra });
- dispatch({ type: 'SET_HEATMAP_PROPS', rowAttr: ra, colAttr: ca });
- dispatch(receiveDataset(ds)); // This goes last, to ensure the above defaults are set when the views are rendered
- dispatch({ type: "SET_VIEW_PROPS", view: "Landscape" });
- })
- // Or, if it failed, dispatch an action to set the error flag
- .catch((err) => {
- console.log(err);
- dispatch(requestDatasetFailed(dataset));
- });
+ dispatch(requestDataSet(dataSetName));
+ // Second, see if the dataset already exists in the store
+ // If not, perform the request (async)
+ return dataSets[dataSetName] === undefined ? (
+ fetch(`/loom/${dataSetName}/fileinfo.json`)
+ .then((response) => { return response.json(); })
+ .then((ds) => {
+ // Once the response comes in, dispatch an action to provide the data
+ // Also, dispatch some actions to set required properties on the subviews
+ // TODO: move to react-router state and
+ // replace with necessary router.push() logic
+ const ra = ds.rowAttrs[0];
+ const ca = ds.colAttrs[0];
+ dispatch({ type: 'SET_GENESCAPE_PROPS', xCoordinate: ra, yCoordinate: ra, colorAttr: ra });
+ dispatch({ type: 'SET_HEATMAP_PROPS', rowAttr: ra, colAttr: ca });
+
+ // This goes last, to ensure the above defaults are set when the views are rendered
+ let receivedDataSet = {};
+ receivedDataSet[dataSetName] = ds;
+ dispatch(receiveDataSet(receivedDataSet));
+ })
+ // Or, if it failed, dispatch an action to set the error flag
+ .catch((err) => {
+ console.log(err);
+ dispatch(requestDataSetFailed(dataSetName));
+ })
+ ) : dispatch(receiveDataSet(
+ { dataSet: dataSets[dataSetName], dataSetName: dataSetName }
+ ));
};
}
@@ -119,20 +151,20 @@ export function fetchDataset(dataset) {
function requestGene(gene) {
return {
- type: 'REQUEST_GENE',
+ type: REQUEST_GENE,
gene: gene,
};
}
function requestGeneFailed() {
return {
- type: 'REQUEST_GENE_FAILED',
+ type: REQUEST_GENE_FAILED,
};
}
function receiveGene(gene, list) {
return {
- type: 'RECEIVE_GENE',
+ type: RECEIVE_GENE,
gene: gene,
data: list,
receivedAt: Date.now(),
@@ -143,8 +175,8 @@ function receiveGene(gene, list) {
// Though its insides are different, you would use it just like any other action creator:
// store.dispatch(fetchgene(...))
-export function fetchGene(dataset, gene, cache) {
- const rowAttrs = dataset.rowAttrs;
+export function fetchGene(dataSet, gene, cache) {
+ const rowAttrs = dataSet.rowAttrs;
return (dispatch) => {
if (!rowAttrs.hasOwnProperty("GeneName")) {
return;
@@ -156,7 +188,7 @@ export function fetchGene(dataset, gene, cache) {
// First, make known the fact that the request has been started
dispatch(requestGene(gene));
// Second, perform the request (async)
- return fetch(`/loom/${dataset.name}/row/${row}`)
+ return fetch(`/loom/${dataSet.name}/row/${row}`)
.then((response) => { return response.json(); })
.then((json) => {
// Third, once the response comes in, dispatch an action to provide the data
diff --git a/client/components/canvas.js b/client/components/canvas.js
new file mode 100644
index 0000000..c8955d5
--- /dev/null
+++ b/client/components/canvas.js
@@ -0,0 +1,69 @@
+import React, {PropTypes} from 'react';
+
+// A simple helper component, wrapping retina logic for canvas.
+// Expects a "painter" function that takes a "context" to draw on.
+// This will draw on the canvas whenever the component updates.
+export class Canvas extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.fitToZoomAndPixelRatio = this.fitToZoomAndPixelRatio.bind(this);
+ this.draw = this.draw.bind(this);
+ }
+
+ // Make sure we get a sharp canvas on Retina displays
+ // as well as adjust the canvas on zoomed browsers
+ fitToZoomAndPixelRatio() {
+ let el = this.refs.canvas;
+ if (el) {
+ let context = el.getContext('2d');
+ const ratio = window.devicePixelRatio || 1;
+ el.width = el.parentNode.clientWidth * ratio;
+ el.height = el.parentNode.clientHeight * ratio;
+ context.mozImageSmoothingEnabled = false;
+ context.webkitImageSmoothingEnabled = false;
+ context.msImageSmoothingEnabled = false;
+ context.imageSmoothingEnabled = false;
+ context.scale(ratio, ratio);
+ context.clearRect(0, 0, el.width, el.height);
+ }
+ }
+
+ draw() {
+ let el = this.refs.canvas;
+ if (el) {
+ this.fitToZoomAndPixelRatio();
+ let context = el.getContext('2d');
+ this.props.paint(context, el.clientWidth, el.clientHeight);
+ }
+ }
+
+ componentDidMount() {
+ this.draw();
+ window.addEventListener("resize", this.draw);
+ }
+
+ componentDidUpdate() {
+ this.draw();
+ }
+
+ render() {
+ return (
+
To generate a dataset, the user must supply the names of:
-
-
the dataset to be created
-
the user the dataset belongs to
-
the project the dataset belongs to
-
-
Furthermore, the pipeline also needs:
-
-
a CSV file of cell attributes from which the dataset is generated
-
(optionally) a CSV file of gene attributes
-
-
Before uploading these CSV files a minimal check will be applied, hopefully catching the most likely
- scenarios.If the CSV file contains semi-colons instead of commas (most likely the result of regional
- settings in whatever tool was used to generate the file), they will automatically be replace
- before submitting.Please double-check if the result is correct in that case.
-
Note: you can still submit a file with a wrong file extension or (what appears to be)
- malformed content, as validation might turn up false positives.We assume you know what you are doing,
- just be careful!
-
Finally, the pipeline requires the following parameters:
-
-
The number of features - at least 100 and not more than the total number of genes in the transcriptome
-
The clustring method to apply - Affinity Propagation or BackSPIN
-
Regression label - must be one of the column attributes
- (either from the file supplied by the user or from the standard cell attributes)
-
-
-
-
+
);
- }
-}
-
-
-class CreateDatasetForm extends Component {
-
- constructor(props, context) {
- super(props, context);
- this.state = {
- n_features: 100,
- cluster_method: '',
- transcriptome: '',
- };
-
- this.handleFormChange = this.handleFormChange.bind(this);
- this.formIsFilled = this.formIsFilled.bind(this);
- this.sendDate = this.sendData.bind(this);
- }
-
- handleFormChange(idx, val) {
- let newState = {};
- newState[idx] = val;
- this.setState(newState);
- }
+ });
+ return (
+
+
+ {datasets}
+
+
+ );
+};
- formIsFilled() {
- let filledForm = true;
- //row_attrs is optional, the rest is not
- const formData = [
- 'transcriptome',
- 'project',
- 'dataset',
- 'col_attrs',
- 'n_features',
- 'cluster_method',
- 'regression_label',
- ];
- formData.forEach((element) => {
- // if an element is missing, we cannot submit
- if (!this.state[element]) {
- filledForm = false;
- }
- });
- return filledForm;
- }
- // See ../docs/loom_server_API.md
- sendData() {
- let FD = new FormData();
+DataSetList.propTypes = {
+ project: PropTypes.string.isRequired,
+ projectState: PropTypes.array.isRequired,
+};
- if (this.formIsFilled()) {
- FD.append('col_attrs', this.state.col_attrs);
- if (this.state.row_attrs) {
- FD.append('row_attrs', this.state.row_attrs);
+// Generates a list of projects, each with a list
+// of datasets associated with the project.
+const ProjectList = function (props) {
+ const { projects } = props;
+ if (projects) {
+ const panels = Object.keys(projects).map(
+ (project) => {
+ return (
+
+ );
}
+ );
- let config = JSON.stringify({
- transcriptome: this.state.transcriptome,
- project: this.state.project,
- dataset: this.state.dataset,
- n_features: this.state.n_features > 100 ? this.state.n_features : 100,
- cluster_method: this.state.cluster_method,
- regression_label: this.state.regression_label,
- });
-
- FD.append('config', config);
-
- let XHR = new XMLHttpRequest();
- //TODO: display server response in the UI
- XHR.addEventListener('load', (event) => { console.log(event); });
- XHR.addEventListener('error', (event) => { console.log(event); });
-
- let urlString = '/loom/' + this.state.transcriptome +
- '__' + this.state.project +
- '__' + this.state.dataset;
- XHR.open('PUT', urlString);
- XHR.send(FD);
-
- }
- }
-
- render() {
- //TODO: fetch this from the server instead of relying on manual inlining
- const transcriptomeOptions = [
- { value: 'mm10_sUCSC', label: 'mm10_sUCSC' },
- { value: 'mm10.2_sUCSC', label: 'mm10.2_sUCSC' },
- { value: 'hg19_sUCSC', label: 'hg19_sUCSC' },
- { value: 'mm10a_sUCSC', label: 'mm10a_sUCSC' },
- { value: 'mm10a_aUCSC', label: 'mm10a_aUCSC' },
- ];
-
- const clusterMethodOptions = [
- { value: 'BackSPIN', label: 'BackSPIN' },
- { value: 'AP', label: 'Affinity Propagation' },
- ];
-
+ return
{panels}
;
+ } else {
return (
-
-
-
-
-
+
);
}
-}
-
-// Valid entries:
-// - may contain letters, numbers and underscores
-// - may NOT contain double (or more) underscores
-// - (optionally) may NOT have leading or trailing underscores
-// This function attempts to autofix names by replacing all sequences of
-// invalid characters (including whitespace) with single underscores.
-// Note that this is purely for user feedback!
-// Input should be validated and fixed on the server side too!
-class LoomTextEntry extends Component {
-
- constructor(props, context) {
- super(props, context);
- this.state = {
- value: this.props.defaultValue,
- fixedVal: '',
- corrected: false,
- };
-
- // fix errors with a small delay after user stops typing
- const delayMillis = 1000;
- this.delayedFixTextInput = _.debounce(this.fixTextInput, delayMillis);
-
- this.handleChange = this.handleChange.bind(this);
- this.fixTextInput = this.fixTextInput.bind(this);
- this.fixString = this.fixString.bind(this);
- }
-
- handleChange(event) {
- const fixedTxt = this.fixString(event.target.value);
- // immediately show newly typed characters
- this.setState({
- value: event.target.value,
- fixedVal: fixedTxt === event.target.value ? fixedTxt : '',
- corrected: false,
- });
- // then make a (debounced) call to fixTextInput
- this.delayedFixTextInput(event.target.value);
- }
-
- fixTextInput(txt) {
- const fixedTxt = this.fixString(txt);
- this.setState({
- value: fixedTxt,
- fixedVal: fixedTxt,
- corrected: fixedTxt !== this.state.value,
- });
- }
+};
- fixString(txt) {
- if (typeof txt === 'string') {
- // replace all non-valid characters with underscores, followed by
- // replacing each sequence of underscores with a single underscore.
- txt = txt.replace(/([^A-Za-z0-9_])+/g, '_')
- .replace(/_+/g, '_');
+ProjectList.propTypes = {
+ projects: PropTypes.object,
+};
- if (this.props.trimTrailingUnderscores || this.props.trimUnderscores) {
- // strip leading/trailing underscore, if present. Note that the
- // above procedure has already reduced any leading underscores
- // to a single character.
- txt = txt.endsWith('_') ? txt.substr(0, txt.length - 1) : txt;
- }
- if (this.props.trimLeadingUnderscores || this.props.trimUnderscores) {
- txt = txt.startsWith('_') ? txt.substr(1) : txt;
- }
- }
- return txt;
- }
+class DataSetViewComponent extends Component {
- shouldComponentUpdate(nextProps, nextState) {
- return (this.state.value !== nextState.value) || (this.state.fixedVal !== nextState.fixedVal);
- }
-
- componentDidUpdate() {
- this.props.onChange(this.state.fixedVal);
+ componentDidMount() {
+ const { dispatch, projects } = this.props;
+ dispatch(fetchProjects(projects));
}
render() {
- let warnStyle = {};
- if (this.state.value !== '') {
- if (this.state.value !== this.fixString(this.state.value)) {
- warnStyle.backgroundColor = '#CC0000';
- warnStyle.color = '#FFFFFF';
- } else if (this.state.corrected) {
- warnStyle.backgroundColor = '#FFA522';
- warnStyle.color = '#222222';
- }
- }
return (
-