mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-26 04:38:11 +08:00
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:
+18
-12
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user