annotations CLI and file UX rework (#1049)

* rename config param label-file

* annotations rework - CLI params, file naming and backups

* lint

* improve cli option error checks

* enable session cookies

* enable session cookies

* add session id

* name annotations file in multi-dataset and multi-user safe manner

* pass data user hash to front-end

* add annotation collection name support to front-end

* add constant for annotation data collection name

* parameterize annotation collection name; make it sticky in the session

* clarify comments

* hard wire a temporary data collection name for testing

* prettier

* test comment

* package command

* set annotations  filename dialog

* name  and hash are visible

* wire up data collection capture
This commit is contained in:
Bruce Martin
2019-11-25 15:28:28 -08:00
committed by GitHub
parent 575f71aaee
commit a593e95ab3
18 changed files with 551 additions and 130 deletions
+18 -12
View File
@@ -38,7 +38,7 @@ const doInitialDataLoad = () =>
/* only load names for var annotations, if possible*/
const varIndexName = schema?.schema?.annotations?.var?.index;
const varAnnotationsQuery = varIndexName
? `?annotation-name=${varIndexName}`
? `?annotation-name=${encodeURIComponent(varIndexName)}`
: "";
const varAnnotationsURL = `annotations/var${varAnnotationsQuery}`;
const requestBinary = ["annotations/obs", varAnnotationsURL, "layout/obs"]
@@ -84,9 +84,7 @@ const regraph = () => (dispatch, getState) => {
// Throws
const dispatchExpressionErrors = (dispatch, res) => {
const msg = `Unexpected HTTP response while fetching expression data ${
res.status
}, ${res.statusText}`;
const msg = `Unexpected HTTP response while fetching expression data ${res.status}, ${res.statusText}`;
dispatchNetworkErrorMessageToUser(msg);
throw new Error(msg);
};
@@ -119,7 +117,8 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
headers: new Headers({
accept: "application/octet-stream",
"Content-Type": "application/json"
})
}),
credentials: "include"
}
);
@@ -226,9 +225,7 @@ const dispatchDiffExpErrors = (dispatch, response) => {
);
break;
default: {
const msg = `Unexpected differential expression HTTP response ${
response.status
}, ${response.statusText}`;
const msg = `Unexpected differential expression HTTP response ${response.status}, ${response.statusText}`;
dispatchNetworkErrorMessageToUser(msg);
dispatch({
type: "request differential expression error",
@@ -277,7 +274,8 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
count: num_genes,
set1: { filter: { obs: { index: set1 } } },
set2: { filter: { obs: { index: set2 } } }
})
}),
credentials: "include"
}
);
@@ -341,8 +339,9 @@ const resetInterface = () => (dispatch, getState) => {
};
const saveObsAnnotations = () => async (dispatch, getState) => {
const { universe } = getState();
const { universe, annotations } = getState();
const { obsAnnotations, schema } = universe;
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
dispatch({
type: "writable obs annotations - save started"
@@ -354,14 +353,21 @@ const saveObsAnnotations = () => async (dispatch, getState) => {
const df = obsAnnotations.subset(null, writableAnnotations);
const matrix = MatrixFBS.encodeMatrixFBS(df);
try {
const queryString =
!dataCollectionNameIsReadOnly && !!dataCollectionName
? `?annotation-collection-name=${encodeURIComponent(
dataCollectionName
)}`
: "";
const res = await fetch(
`${globals.API.prefix}${globals.API.version}annotations/obs`,
`${globals.API.prefix}${globals.API.version}annotations/obs${queryString}`,
{
method: "PUT",
body: matrix,
headers: new Headers({
"Content-Type": "application/octet-stream"
})
}),
credentials: "include"
}
);
if (res.ok) {
@@ -0,0 +1,168 @@
import React from "react";
import { connect } from "react-redux";
import {
Button,
Tooltip,
InputGroup,
Dialog,
Classes,
Colors
} from "@blueprintjs/core";
@connect(state => ({
universe: state.universe,
idhash: state.config?.parameters?.["annotations-user-data-idhash"] ?? null,
annotations: state.annotations,
obsAnnotations: state.universe.obsAnnotations,
saveInProgress: state.autosave?.saveInProgress ?? false,
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
error: state.autosave?.error,
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false
}))
class FilenameDialog extends React.Component {
constructor(props) {
super(props);
this.state = {
filenameText: ""
};
}
dismissFilenameDialog = () => {};
handleCreateFilename = () => {
const { dispatch } = this.props;
const { filenameText } = this.state;
dispatch({
type: "set annotations collection name",
data: filenameText
});
};
filenameError = () => {
const legalNames = /^\w+$/;
const { filenameText } = this.state;
let err = false;
if (filenameText === "") {
err = "empty_string";
} else if (!legalNames.test(filenameText)) {
/*
IMPORTANT: this test must ultimately match the test applied by the
backend, which is designed to ensure a safe file name can be created
from the data collection name. If you change this, you will also need
to change the validation code in the backend, or it will have no effect.
*/
err = "characters";
}
return err;
};
filenameErrorMessage = () => {
const err = this.filenameError();
let markup = null;
if (err === "empty_string") {
markup = (
<span
style={{
fontStyle: "italic",
fontSize: 12,
marginTop: 5,
color: Colors.ORANGE3
}}
>
{"Filename cannot be blank"}
</span>
);
} else if (err === "characters") {
markup = (
<span
style={{
fontStyle: "italic",
fontSize: 12,
marginTop: 5,
color: Colors.ORANGE3
}}
>
{"Only alphanumeric and underscore allowed"}
</span>
);
}
return markup;
};
render() {
const { writableCategoriesEnabled, annotations, idhash } = this.props;
const { filenameText } = this.state;
return writableCategoriesEnabled &&
!annotations.dataCollectionNameIsReadOnly &&
!annotations.dataCollectionName ? (
<Dialog
icon="tag"
title="Annotations Collection"
isOpen={!annotations.dataCollectionName}
onClose={this.dismissFilenameDialog}
>
<form
onSubmit={e => {
e.preventDefault();
this.handleCreateFilename();
}}
>
<div className={Classes.DIALOG_BODY}>
<div style={{ marginBottom: 20 }}>
<p>Name your collection of user generated annotations:</p>
<InputGroup
autoFocus
value={filenameText}
intent={this.filenameError(filenameText) ? "warning" : "none"}
onChange={e => this.setState({ filenameText: e.target.value })}
leftIcon="tag"
/>
<p
style={{
marginTop: 7,
visibility: this.filenameError(filenameText)
? "visible"
: "hidden",
color: Colors.ORANGE3
}}
>
{this.filenameErrorMessage(filenameText)}
</p>
</div>
<div>
<p>
You can find your collection at:{" "}
<code className="bp3-code">
{filenameText}-{idhash}.csv
</code>
</p>
</div>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Tooltip content="Cancel naming collection">
<Button onClick={this.dismissFilenameDialog}>Cancel</Button>
</Tooltip>
<Button
disabled={!filenameText || this.filenameError(filenameText)}
onClick={this.handleCreateFilename}
intent="primary"
type="submit"
>
Create annotations collection
</Button>
</div>
</div>
</form>
</Dialog>
) : null;
}
}
export default FilenameDialog;
+4 -1
View File
@@ -2,14 +2,16 @@ import React from "react";
import { connect } from "react-redux";
import * as globals from "../../globals";
import actions from "../../actions";
import FilenameDialog from "./filenameDialog";
@connect(state => ({
universe: state.universe,
annotations: state.annotations,
obsAnnotations: state.universe.obsAnnotations,
saveInProgress: state.autosave?.saveInProgress ?? false,
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
error: state.autosave?.error,
writableCategoriesEnabled: state.config?.parameters?.["label_file"] ?? false
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false
}))
class Autosave extends React.Component {
constructor(props) {
@@ -73,6 +75,7 @@ class Autosave extends React.Component {
}}
>
{this.statusMessage()}
<FilenameDialog />
</div>
) : null;
}
@@ -18,7 +18,7 @@ import { AnnotationsHelpers } from "../../util/stateManager";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
writableCategoriesEnabled: state.config?.parameters?.["label_file"] ?? false,
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false,
schema: state.world?.schema
}))
class Categories extends React.Component {
+3 -2
View File
@@ -84,7 +84,6 @@ class CategoryValue extends React.Component {
};
valueNameErrorMessage = () => {
const { editedLabelText } = this.state;
const err = this.valueNameError();
if (err === false) return null;
@@ -555,7 +554,9 @@ class CategoryValue extends React.Component {
data-testclass="handleDeleteValue"
data-testid={`handleDeleteValue-${metadataField}`}
onClick={this.handleDeleteValue}
text={`Delete this label, and reassign all cells to type '${globals.unassignedCategoryLabel}'`}
text={`Delete this label, and reassign all cells to type '${
globals.unassignedCategoryLabel
}'`}
/>
) : null}
</Menu>
+43
View File
@@ -3,6 +3,24 @@ Reducers for annotation UI-state.
*/
const Annotations = (
state = {
/*
Annotations collection name - which will be used to save the named set of annotations
in some persistent store (database, file system, etc).
The backend may expect this to be a legal file name, which is typically alpha-numeric, plus [_-,.].
Keep it simple or the server may return an error.
If `dataCollectionNameIsReadOnly` is true, you may NOT change the data collection name.
If false, you may change `dataCollectionName` and it will be used at the time the annotations are
written to the back-end.
*/
dataCollectionNameIsReadOnly: true,
dataCollectionName: null,
/*
Annotations UI component state
*/
isEditingCategoryName: false,
isEditingLabelName: false,
categoryBeingEdited: null,
@@ -12,6 +30,31 @@ const Annotations = (
action
) => {
switch (action.type) {
case "configuration load complete": {
const DefaultDataCollectionName = null;
const dataCollectionName =
action.config.parameters?.["annotations-data-collection-name"] ?? null;
const dataCollectionNameIsReadOnly =
action.config.parameters?.[
"annotations-data-collection-name-is-read-only"
] ?? false;
return {
...state,
dataCollectionNameIsReadOnly,
dataCollectionName
};
}
case "set annotations collection name": {
if (state.dataCollectionNameIsReadOnly) {
throw new Error("data collection name is read only");
}
return {
...state,
dataCollectionName: action.data
};
}
/* CATEGORY */
case "annotation: activate add new label mode":
return {
+2 -1
View File
@@ -33,7 +33,8 @@ const doFetch = async (url, acceptType) => {
method: "get",
headers: new Headers({
Accept: acceptType
})
}),
credentials: "include"
});
if (res.ok && res.headers.get("Content-Type").includes(acceptType)) {
return res;