Ontologies (#1110)

* add sample ontologies file

* add ontologies reducer

* Move select category to own component

* Dialog and Input factored out

* refactoring categorical, partway

* validationn

* anno

* suggest  populates input

* frontend for ontology working

* initial implementation of back-end support for ontologies

* edit is now dialog again

* autosuggest working on edit

* part way through create arbitrary label

* handle choice in function

* pass duplicate cat  prop

* editing works

* update test to match new CLI params

* fix occupancy alignment

* edit category as dialogue

* secondary button

* remove stubbed out ontologies

* add label setting upon new label creation

* Update legal characters for labels (#1119)

* Allow any term in the ontology (bypass legal name check)

* Add hyphens and parens to legal characters in names

* improve performance for large ontologies

* correctly handle case where ontologies are disabled

* fix logic error in CLI

Co-authored-by: Bruce Martin <bruce@chanzuckerberg.com>

* PR cleanup 1

* lint

* validate user generated labels

* finish hooking up connected suggest component

* protect against undefined callbacks

* Fix illegal characters error message

* break out npm run commands

* fix error detection on label edit

Co-authored-by: Bruce Martin <bruce@chanzuckerberg.com>
Co-authored-by: Sidney Bell <sidneymbell@users.noreply.github.com>
This commit is contained in:
Colin Megill
2020-01-23 17:04:17 -05:00
committed by GitHub
co-authored by Bruce Martin Sidney Bell
parent 8d725b1ad9
commit d48647a655
20 changed files with 964 additions and 532 deletions
@@ -0,0 +1,91 @@
import React from "react";
import { connect } from "react-redux";
import { Button, Tooltip, Dialog, Classes, Colors } from "@blueprintjs/core";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading
}))
class AnnoDialog extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
const {
isActive,
text,
title,
instruction,
cancelTooltipContent,
errorMessage,
validationError,
annoSelect,
annoInput,
handleCancel,
handleSubmit,
primaryButtonText,
secondaryButtonText,
handleSecondaryButtonSubmit
} = this.props;
return (
<Dialog icon="tag" title={title} isOpen={isActive} onClose={handleCancel}>
<form
onSubmit={e => {
e.preventDefault();
}}
>
<div className={Classes.DIALOG_BODY}>
<div style={{ marginBottom: 20 }}>
<p>{instruction}</p>
{annoInput || null}
<p
style={{
marginTop: 7,
visibility: validationError ? "visible" : "hidden",
color: Colors.ORANGE3
}}
>
{errorMessage}
</p>
</div>
{annoSelect || null}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Tooltip content={cancelTooltipContent}>
<Button onClick={handleCancel}>Cancel</Button>
</Tooltip>
{handleSecondaryButtonSubmit && secondaryButtonText ? (
<Button
onClick={handleSecondaryButtonSubmit}
disabled={!text || validationError}
intent="none"
type="submit"
>
{secondaryButtonText}
</Button>
) : null}
<Button
onClick={handleSubmit}
disabled={!text || validationError}
intent="primary"
type="submit"
>
{primaryButtonText}
</Button>
</div>
</div>
</form>
</Dialog>
);
}
}
export default AnnoDialog;
@@ -0,0 +1,159 @@
import React from "react";
import { connect } from "react-redux";
import { InputGroup, MenuItem } from "@blueprintjs/core";
import { Suggest } from "@blueprintjs/select";
import fuzzysort from "fuzzysort";
const filterOntology = (query, ontology) =>
/* fires on load, once, and then for each character typed into the input */
fuzzysort.go(query, ontology, {
limit: 100,
threshold: -10000 // don't return bad results
});
const renderListItem = (fuzzySortResult, { handleClick, modifiers }) => {
if (!modifiers.matchesPredicate) {
return null;
}
/* the fuzzysort wraps the object with other properties, like a score */
const geneName = fuzzySortResult.target;
return (
<MenuItem
active={modifiers.active}
disabled={modifiers.disabled}
data-testid={`suggest-menu-item-${geneName}`}
// Use of annotations in this way is incorrect and dataset specific.
// See https://github.com/chanzuckerberg/cellxgene/issues/483
// label={gene.n_counts}
key={geneName}
onClick={g =>
/* this fires when user clicks a menu item */
handleClick(g)
}
text={geneName}
/>
);
};
const AnnoSuggest = props => {
const {
ontologyLoading,
handleItemChange,
handleChoice,
ontology,
handleCreateArbitraryLabel,
handleTextChange,
isTextInvalid,
isTextInvalidErrorMessage
} = props;
return (
<Suggest
fill
resetOnSelect
closeOnSelect
resetOnClose
createNewItemFromQuery={() => {}}
createNewItemRenderer={userInputStr => {
return isTextInvalid?.(userInputStr) ? (
<MenuItem disabled text={isTextInvalidErrorMessage(userInputStr)} />
) : (
<MenuItem
icon="add"
text="Create a label not in the ontology"
active
onClick={() => {
handleCreateArbitraryLabel(userInputStr);
}}
shouldDismissPopover={false}
/>
);
}}
itemDisabled={ontologyLoading ? () => true : () => false}
noResults={<MenuItem disabled text="No matching ontology identifier" />}
onItemSelect={handleChoice}
initialContent={
<MenuItem disabled text="Enter an ontology identifier…" />
}
inputProps={{ "data-testid": "gene-search" }}
inputValueRenderer={t => t.target}
itemListPredicate={filterOntology}
onActiveItemChange={handleItemChange}
itemRenderer={renderListItem.bind(this)}
items={!ontologyLoading && ontology ? ontology : ["No ontology loaded"]}
popoverProps={{ minimal: true }}
onQueryChange={(s, e) => {
// undefined event means resetOnSelect
if (e !== undefined) handleTextChange?.(s);
}}
/>
);
};
const VanillaInput = props => {
const { text, handleTextChange } = props;
return (
<InputGroup
autoFocus
value={text}
intent="none"
onChange={e => handleTextChange?.(e.target.value)}
leftIcon="tag"
/>
);
};
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
world: state.world,
ontology: state.ontology?.terms,
ontologyLoading: state.ontology?.loading
}))
class AnnoInputs extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
const {
useSuggest,
handleTextChange,
text,
ontologyLoading,
world,
handleChoice,
handleItemChange,
handleCreateArbitraryLabel,
ontology,
isTextInvalid,
isTextInvalidErrorMessage
} = this.props;
return (
<div>
{/* Le sigh https://github.com/palantir/blueprint/issues/2864 */}
{useSuggest ? (
<AnnoSuggest
world={world}
text={text}
handleChoice={handleChoice}
ontologyLoading={ontologyLoading}
handleCreateArbitraryLabel={handleCreateArbitraryLabel}
handleItemChange={handleItemChange}
ontology={ontology}
handleTextChange={handleTextChange}
isTextInvalid={isTextInvalid}
isTextInvalidErrorMessage={isTextInvalidErrorMessage}
/>
) : (
<VanillaInput text={text} handleTextChange={handleTextChange} />
)}
</div>
);
}
}
export default AnnoInputs;
@@ -0,0 +1,57 @@
import React from "react";
import { connect } from "react-redux";
import { Button, MenuItem } from "@blueprintjs/core";
import { Select } from "@blueprintjs/select";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading
}))
class DuplicateCategorySelect extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
const {
allCategoryNames,
categoryToDuplicate,
handleModalDuplicateCategorySelection
} = this.props;
return (
<div>
<p>
Optionally duplicate all labels & cell assignments from existing
category into new category:
</p>
<Select
items={
allCategoryNames ||
[] /* this is a placeholder, could be a subcomponent to avoid this */
}
filterable={false}
itemRenderer={(d, { handleClick }) => {
return <MenuItem onClick={handleClick} key={d} text={d} />;
}}
noResults={<MenuItem disabled text="No results." />}
onItemSelect={d => {
handleModalDuplicateCategorySelection(d);
}}
>
{/* children become the popover target; render value here */}
<Button
text={categoryToDuplicate || "None (all cells 'unassigned')"}
rightIcon="double-caret-vertical"
/>
</Select>
</div>
);
}
}
export default DuplicateCategorySelect;
+50 -103
View File
@@ -1,20 +1,13 @@
// jshint esversion: 6
import React from "react";
import _ from "lodash";
import {
Button,
Tooltip,
InputGroup,
Dialog,
Classes,
MenuItem,
Colors
} from "@blueprintjs/core";
import { Select } from "@blueprintjs/select";
import { Button } from "@blueprintjs/core";
import { connect } from "react-redux";
import * as globals from "../../globals";
import Category from "./category";
import { AnnotationsHelpers } from "../../util/stateManager";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import AnnoSelect from "./annoSelect";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
@@ -71,7 +64,7 @@ class Categories extends React.Component {
/* allow empty string */
if (name === "") return false;
/*
/*
test for uniqueness against *all* annotation names, not just the subset
we render as categorical.
*/
@@ -102,13 +95,25 @@ class Categories extends React.Component {
"empty-string": "Blank names not allowed",
duplicate: "Name must be unique",
"trim-spaces": "Leading and trailing spaces not allowed",
"illegal-characters": "Only alphanumeric, underscore and period allowed",
"illegal-characters":
"Only alphanumeric and special characters (-_.) allowed",
"multi-space-run": "Multiple consecutive spaces not allowed"
};
const errorMessage = errorMessageMap[err] ?? "error";
return <span>{errorMessage}</span>;
};
handleNewCategoryText = txt => {
this.setState({ newCategoryText: txt });
};
handleChoice = e => {
/* Blueprint Suggest format */
this.setState({ newCategoryText: e.target });
};
handleSuggestActiveItemChange = () => {};
render() {
const {
createAnnoModeActive,
@@ -131,9 +136,39 @@ class Categories extends React.Component {
padding: globals.leftSidebarSectionPadding
}}
>
<AnnoDialog
isActive={createAnnoModeActive}
title="Create new category"
instruction="New, unique category name:"
cancelTooltipContent="Close this dialog without creating a category."
primaryButtonText="Create new category"
text={newCategoryText}
validationError={this.categoryNameError(newCategoryText)}
errorMessage={this.categoryNameErrorMessage(newCategoryText)}
handleSubmit={this.handleCreateUserAnno}
handleCancel={this.handleDisableAnnoMode}
annoInput={
<AnnoInputs
text={newCategoryText}
handleItemChange={this.handleSuggestActiveItemChange}
handleChoice={this.handleChoice}
handleTextChange={this.handleNewCategoryText}
/>
}
annoSelect={
<AnnoSelect
handleModalDuplicateCategorySelection={
this.handleModalDuplicateCategorySelection
}
categoryToDuplicate={categoryToDuplicate}
allCategoryNames={allCategoryNames}
/>
}
/>
{/* READ ONLY CATEGORICAL FIELDS */}
{/* this is duplicative but flat, could be abstracted */}
{_.map(allCategoryNames, catName =>
{allCategoryNames.map(catName =>
!schema.annotations.obsByName[catName].writable ? (
<Category
key={catName}
@@ -144,7 +179,7 @@ class Categories extends React.Component {
) : null
)}
{/* WRITEABLE FIELDS */}
{_.map(allCategoryNames, catName =>
{allCategoryNames.map(catName =>
schema.annotations.obsByName[catName].writable ? (
<Category
key={catName}
@@ -156,94 +191,6 @@ class Categories extends React.Component {
)}
{writableCategoriesEnabled ? (
<div>
<Dialog
icon="tag"
title="Create new category"
isOpen={createAnnoModeActive}
onClose={this.handleDisableAnnoMode}
>
<form
onSubmit={e => {
e.preventDefault();
}}
>
<div className={Classes.DIALOG_BODY}>
<div style={{ marginBottom: 20 }}>
<p>New, unique category name:</p>
<InputGroup
autoFocus
value={newCategoryText}
intent={
this.categoryNameError(newCategoryText)
? "warning"
: "none"
}
onChange={e =>
this.setState({ newCategoryText: e.target.value })
}
leftIcon="tag"
/>
<p
style={{
marginTop: 7,
visibility: this.categoryNameError(newCategoryText)
? "visible"
: "hidden",
color: Colors.ORANGE3
}}
>
{this.categoryNameErrorMessage(newCategoryText)}
</p>
</div>
<p>
Optionally duplicate all labels & cell assignments from
existing category into new category:
</p>
<Select
items={allCategoryNames}
filterable={false}
itemRenderer={(d, { handleClick }) => {
return (
<MenuItem onClick={handleClick} key={d} text={d} />
);
}}
noResults={<MenuItem disabled text="No results." />}
onItemSelect={d => {
this.handleModalDuplicateCategorySelection(d);
}}
>
{/* children become the popover target; render value here */}
<Button
text={
categoryToDuplicate || "None (all cells 'unassigned')"
}
rightIcon="double-caret-vertical"
/>
</Select>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Tooltip content="Close this dialog without creating a category.">
<Button onClick={this.handleDisableAnnoMode}>
Cancel
</Button>
</Tooltip>
<Button
onClick={this.handleCreateUserAnno}
disabled={
!newCategoryText ||
this.categoryNameError(newCategoryText)
}
intent="primary"
type="submit"
>
Create new category
</Button>
</div>
</div>
</form>
</Dialog>
<Button onClick={this.handleEnableAnnoMode} intent="primary">
Create new category
</Button>
+110 -161
View File
@@ -6,28 +6,30 @@ import { Flipper, Flipped } from "react-flip-toolkit";
import {
Button,
Tooltip,
InputGroup,
Menu,
Dialog,
MenuItem,
Popover,
Classes,
Icon,
Position,
PopoverInteractionKind,
Colors
} from "@blueprintjs/core";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import * as globals from "../../globals";
import Value from "./value";
import sortedCategoryLabels from "../../util/catLabelSort";
import { AnnotationsHelpers } from "../../util/stateManager";
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
}))
class Category extends React.Component {
constructor(props) {
@@ -91,14 +93,48 @@ class Category extends React.Component {
handleAddNewLabelToCategory = () => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText
newLabelText,
assignSelectedCells: false
});
this.setState({ newLabelText: "" });
};
addLabelAndAssignCells = () => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText,
assignSelectedCells: true
});
this.setState({ newLabelText: "" });
};
handleCreateArbitraryLabel = newLabelTextNotInOntology => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText: newLabelTextNotInOntology,
assignSelectedCells: false
});
this.setState({ newLabelText: "" });
};
handleCategoryEditTextChange = txt => {
this.setState({
newCategoryText: txt
});
};
activateEditCategoryMode = () => {
const { dispatch, metadataField } = this.props;
@@ -154,63 +190,16 @@ class Category extends React.Component {
};
labelNameError = name => {
/*
return false if this is a LEGAL/acceptable category name or NULL/empty string,
or return an error type.
*/
/* allow empty string */
if (name === "") return false;
/* check for label syntax errors */
const error = AnnotationsHelpers.annotationNameIsErroneous(name);
if (error) return error;
/* disallow duplicates */
const { metadataField, universe } = this.props;
const { obsByName } = universe.schema.annotations;
if (obsByName[metadataField].categories.indexOf(name) !== -1)
return "duplicate";
/* otherwise, no error */
return false;
const { metadataField, ontology, universe } = this.props;
return isLabelErroneous(name, metadataField, ontology, universe.schema);
};
labelNameErrorMessage = name => {
const { metadataField } = this.props;
const err = this.labelNameError(name);
if (err === "duplicate") {
/* duplicate error is special cased because it has special formatting */
return (
<span>
<span style={{ fontStyle: "italic" }}>{name}</span> already exists
already exists within{" "}
<span style={{ fontStyle: "italic" }}>{metadataField}</span>{" "}
</span>
);
}
if (err) {
/* all other errors - map code to human error message */
const errorMessageMap = {
"empty-string": "Blank names not allowed",
duplicate: "Name must be unique",
"trim-spaces": "Leading and trailing spaces not allowed",
"illegal-characters":
"Only alphanumeric, underscore and period allowed",
"multi-space-run": "Multiple consecutive spaces not allowed"
};
const errorMessage = errorMessageMap[err] ?? "error";
return <span>{errorMessage}</span>;
}
/* no error, no message generated */
return null;
const { metadataField, ontology, universe } = this.props;
return labelErrorMessage(name, metadataField, ontology, universe.schema);
};
categoryNameErrorMessage = () => {
const { newCategoryText } = this.state;
const err = this.editedCategoryNameError();
if (err === false) return null;
@@ -219,7 +208,8 @@ class Category extends React.Component {
"empty-string": "Blank names not allowed",
duplicate: "Category name must be unique",
"trim-spaces": "Leading and trailing spaces not allowed",
"illegal-characters": "Only alphanumeric, underscore and period allowed",
"illegal-characters":
"Only alphanumeric and special characters (-_.) allowed",
"multi-space-run": "Multiple consecutive spaces not allowed"
};
const errorMessage = errorMessageMap[err] ?? "error";
@@ -261,6 +251,15 @@ class Category extends React.Component {
return false;
};
/* leaky to have both of these in multiple components */
handleChoice = e => {
this.setState({ newLabelText: e.target });
};
handleTextChange = text => {
this.setState({ newLabelText: text });
};
toggleAll() {
const { dispatch, metadataField } = this.props;
dispatch({
@@ -318,13 +317,13 @@ class Category extends React.Component {
colorAccessor,
categoricalSelection,
isUserAnno,
annotations
annotations,
ontologyEnabled
} = this.props;
const { isTruncated } = categoricalSelection[metadataField];
const cat = categoricalSelection[metadataField];
const optTuples = [...cat.categoryValueIndices];
const optTuplesAsKey = _.map(optTuples, t => t[0]).join(""); // animation
const allCategoryNames = _.keys(categoricalSelection);
return (
<div
@@ -381,46 +380,30 @@ class Category extends React.Component {
<Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} />
) : null}
{annotations.isEditingCategoryName &&
annotations.categoryBeingEdited === metadataField ? (
<form
style={{ display: "inline-block" }}
onSubmit={e => {
e.preventDefault();
this.handleEditCategory();
}}
>
<InputGroup
style={{ position: "relative", top: -1 }}
ref={input => {
this.editableCategoryInput = input;
}}
small
autoFocus
onChange={e => {
this.setState({
newCategoryText: e.target.value
});
}}
defaultValue={metadataField}
rightElement={
<Button
minimal
disabled={this.editedCategoryNameError()}
style={{ position: "relative", top: -1 }}
type="button"
icon="small-tick"
data-testclass="submitCategoryNameEdit"
data-testid="submitCategoryNameEdit"
onClick={this.handleEditCategory}
/>
}
{metadataField}
<AnnoDialog
isActive={
annotations.isEditingCategoryName &&
annotations.categoryBeingEdited === metadataField
}
title="Edit category name"
instruction="New, unique category name:"
cancelTooltipContent="Close this dialog without editing this category."
primaryButtonText="Edit category name"
text={newCategoryText}
validationError={this.editedCategoryNameError(newCategoryText)}
errorMessage={this.categoryNameErrorMessage(newCategoryText)}
handleSubmit={this.handleEditCategory}
handleCancel={this.disableEditCategoryMode}
annoInput={
<AnnoInputs
useSuggest={false}
text={newCategoryText}
handleTextChange={this.handleCategoryEditTextChange}
/>
{this.categoryNameErrorMessage()}
</form>
) : (
metadataField
)}
}
/>
{isExpanded ? (
<FaChevronDown
@@ -438,71 +421,37 @@ class Category extends React.Component {
<div>
{isUserAnno ? (
<>
<Dialog
icon="tag"
title="Add new label"
isOpen={
<AnnoDialog
isActive={
annotations.isAddingNewLabel &&
annotations.categoryAddingNewLabel === metadataField
}
onClose={this.disableAddNewLabelMode}
>
<form
onSubmit={e => {
e.preventDefault();
this.handleAddNewLabelToCategory();
}}
>
<div className={Classes.DIALOG_BODY}>
<div style={{ marginBottom: 20 }}>
<p>New, unique label name:</p>
<InputGroup
autoFocus
value={newLabelText}
intent={
this.labelNameError(newLabelText)
? "warning"
: "none"
}
onChange={e =>
this.setState({ newLabelText: e.target.value })
}
leftIcon="tag"
/>
<p
style={{
marginTop: 7,
visibility: this.labelNameError(newLabelText)
? "visible"
: "hidden",
color: Colors.ORANGE3
}}
>
{this.labelNameErrorMessage(newLabelText)}
</p>
</div>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Tooltip content="Close this dialog without adding a label.">
<Button onClick={this.disableAddNewLabelMode}>
Cancel
</Button>
</Tooltip>
<Button
disabled={
!newLabelText || this.labelNameError(newLabelText)
}
onClick={this.handleAddNewLabelToCategory}
intent="primary"
type="submit"
>
Add new label to category
</Button>
</div>
</div>
</form>
</Dialog>
title="Add new label to category"
instruction="New, unique label name:"
cancelTooltipContent="Close this dialog without adding a label."
primaryButtonText="Add"
secondaryButtonText="Add label & assign currently selected cells"
handleSecondaryButtonSubmit={this.addLabelAndAssignCells}
text={newLabelText}
validationError={this.labelNameError(newLabelText)}
errorMessage={this.labelNameErrorMessage(newLabelText)}
handleSubmit={this.handleAddNewLabelToCategory}
handleCancel={this.disableAddNewLabelMode}
annoInput={
<AnnoInputs
useSuggest={ontologyEnabled}
text={newLabelText}
handleCreateArbitraryLabel={
this.handleCreateArbitraryLabel
}
handleItemChange={this.handleSuggestActiveItemChange}
handleChoice={this.handleChoice}
handleTextChange={this.handleTextChange}
isTextInvalid={this.labelNameError}
isTextInvalidErrorMessage={this.labelNameErrorMessage}
/>
}
/>
<Popover
interactionKind={PopoverInteractionKind.HOVER}
boundary="window"
@@ -0,0 +1,57 @@
import React from "react";
import { AnnotationsHelpers } from "../../util/stateManager";
export function isLabelErroneous(label, metadataField, ontology, schema) {
/*
return false if this is a LEGAL/acceptable category name or NULL/empty string,
or return an error type.
*/
/* allow empty string */
if (label === "") return false;
/* check for label syntax errors, but allow terms in ontology */
const termInOntology = ontology?.termSet.has(label) ?? false;
const error = AnnotationsHelpers.annotationNameIsErroneous(label);
if (error && !termInOntology) return error;
/* disallow duplicates */
const { obsByName } = schema.annotations;
if (obsByName[metadataField].categories.indexOf(label) !== -1)
return "duplicate";
/* otherwise, no error */
return false;
}
export function labelErrorMessage(label, metadataField, ontology, schema) {
const err = isLabelErroneous(label, metadataField, ontology, schema);
if (err === "duplicate") {
/* duplicate error is special cased because it has special formatting */
return (
<span>
<span style={{ fontStyle: "italic" }}>{label}</span> already
exists already exists within{" "}
<span style={{ fontStyle: "italic" }}>{metadataField}</span>{" "}
</span>
);
}
if (err) {
/* all other errors - map code to human error message */
const errorMessageMap = {
"empty-string": "Blank names not allowed",
duplicate: "Name must be unique",
"trim-spaces": "Leading and trailing spaces not allowed",
"illegal-characters":
"Only alphanumeric and special characters (-_.) allowed",
"multi-space-run": "Multiple consecutive spaces not allowed"
};
const errorMessage = errorMessageMap[err] ?? "error";
return <span>{errorMessage}</span>;
}
/* no error, no message generated */
return null;
}
+215 -228
View File
@@ -4,7 +4,6 @@ import React from "react";
import {
Button,
InputGroup,
Menu,
MenuItem,
Popover,
@@ -16,8 +15,11 @@ import {
import Occupancy from "./occupancy";
import * as globals from "../../globals";
import styles from "./categorical.css";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import { AnnotationsHelpers } from "../../util/stateManager";
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
@@ -27,7 +29,10 @@ import { AnnotationsHelpers } from "../../util/stateManager";
pointDilation: state.pointDilation,
schema: state.world?.schema,
world: state.world,
crossfilter: state.crossfilter
crossfilter: state.crossfilter,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
}))
class CategoryValue extends React.Component {
constructor(props) {
@@ -41,13 +46,20 @@ class CategoryValue extends React.Component {
};
}
getLabel = () => {
const { metadataField, categoryIndex, categoricalSelection } = this.props;
const category = categoricalSelection[metadataField];
const label = category.categoryValues[categoryIndex];
return label;
};
componentDidUpdate(prevProps) {
const { categoricalSelection, metadataField, categoryIndex } = this.props;
if (
prevProps.categoricalSelection !== categoricalSelection ||
prevProps.metadataField !== metadataField ||
prevProps.categoryIndex !== categoryIndex
) {
this.setState({
editedLabelText: String(
categoricalSelection[metadataField].categoryValues[categoryIndex]
).valueOf()
});
}
}
handleDeleteValue = () => {
const { dispatch, metadataField } = this.props;
@@ -84,60 +96,49 @@ class CategoryValue extends React.Component {
});
};
valueNameErrorMessage = () => {
const err = this.valueNameError();
if (err === false) return null;
handleCreateArbitraryLabel = editedLabelTextNotInOntology => {
const { dispatch, metadataField, categoryIndex } = this.props;
const label = this.getLabel();
const errorMessageMap = {
/* map error code to human readable error message */
"empty-string": "Blank names not allowed",
duplicate: "Label must be unique",
"trim-spaces": "Leading and trailing spaces not allowed",
"illegal-characters": "Only alphanumeric, underscore and period allowed",
"multi-space-run": "Multiple consecutive spaces not allowed"
};
const errorMessage = errorMessageMap[err] ?? "error";
return (
<span
style={{
fontStyle: "italic",
fontSize: 12,
marginTop: 5,
color: Colors.ORANGE3
}}
>
{errorMessage}
</span>
);
dispatch({
type: "annotation: label edited",
metadataField,
editedLabel: editedLabelTextNotInOntology,
categoryIndex,
label
});
};
valueNameError = () => {
const { editedLabelText } = this.state;
const { categoricalSelection, metadataField, categoryIndex } = this.props;
/*
check label syntax
*/
const err = AnnotationsHelpers.annotationNameIsErroneous(editedLabelText);
if (err) return err;
/*
disallow duplicates
*/
labelNameError = name => {
const {
metadataField,
ontology,
schema,
categoricalSelection,
categoryIndex
} = this.props;
const category = categoricalSelection[metadataField];
const displayString = String(
category.categoryValues[categoryIndex]
).valueOf();
if (
category.categoryValues.indexOf(editedLabelText) > -1 &&
editedLabelText !== displayString
)
return "duplicate";
if (name === displayString) return false;
return isLabelErroneous(name, metadataField, ontology, schema);
};
/*
otherwise, all good!
*/
return false;
labelNameErrorMessage = name => {
const {
metadataField,
ontology,
schema,
categoricalSelection,
categoryIndex
} = this.props;
const category = categoricalSelection[metadataField];
const displayString = String(
category.categoryValues[categoryIndex]
).valueOf();
if (name === displayString) return null;
return labelErrorMessage(name, metadataField, ontology, schema);
};
activateEditLabelMode = () => {
@@ -208,21 +209,6 @@ class CategoryValue extends React.Component {
);
};
componentDidUpdate(prevProps) {
const { categoricalSelection, metadataField, categoryIndex } = this.props;
if (
prevProps.categoricalSelection !== categoricalSelection ||
prevProps.metadataField !== metadataField ||
prevProps.categoryIndex !== categoryIndex
) {
this.setState({
editedLabelText: String(
categoricalSelection[metadataField].categoryValues[categoryIndex]
).valueOf()
});
}
}
toggleOn = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
@@ -250,6 +236,23 @@ class CategoryValue extends React.Component {
});
};
handleTextChange = text => {
this.setState({ editedLabelText: text });
};
handleChoice = e => {
/* Blueprint Suggest format */
this.setState({ editedLabelText: e.target });
};
getLabel = () => {
const { metadataField, categoryIndex, categoricalSelection } = this.props;
const category = categoricalSelection[metadataField];
const label = category.categoryValues[categoryIndex];
return label;
};
isAddCurrentSelectionDisabled(category, value) {
/*
disable "add current selection to label", if one of the following is true:
@@ -289,6 +292,7 @@ class CategoryValue extends React.Component {
schema,
isUserAnno,
annotations,
ontologyEnabled,
// flippedProps is potentially brittle, their docs want {...flippedProps} on our div,
// our lint doesn't like jsx spread, we are version pinned to prevent api change on their part
flippedProps,
@@ -339,6 +343,12 @@ class CategoryValue extends React.Component {
)}`;
}
const editModeActive =
isUserAnno &&
annotations.labelEditable.category === metadataField &&
annotations.isEditingLabelName &&
annotations.labelEditable.label === categoryIndex;
return (
<div
key={i}
@@ -416,66 +426,41 @@ class CategoryValue extends React.Component {
verticalAlign: "middle"
}}
>
{annotations.isEditingLabelName &&
annotations.labelEditable.category === metadataField &&
annotations.labelEditable.label === categoryIndex
? null
: truncatedString || displayString}
{truncatedString || displayString}
</span>
</Tooltip>
{isUserAnno &&
annotations.labelEditable.category === metadataField &&
annotations.isEditingLabelName &&
annotations.labelEditable.label === categoryIndex ? (
<form
onSubmit={e => {
e.preventDefault();
if (this.valueNameError()) {
return;
}
this.handleEditValue();
}}
>
<InputGroup
style={{ position: "relative", top: -1 }}
ref={input => {
this.editableInput = input;
}}
small
autoFocus
intent={this.valueNameError() ? "warning" : "none"}
onChange={e => {
this.setState({ editedLabelText: e.target.value });
}}
defaultValue={displayString}
rightElement={
<Button
minimal
style={{ position: "relative", top: -1 }}
disabled={this.valueNameError()}
type="button"
icon="small-tick"
data-testclass="submitEdit"
data-testid="submitEdit"
onClick={this.handleEditValue}
{editModeActive ? (
<div>
<AnnoDialog
isActive={editModeActive}
title="Edit label"
instruction={`New label text must be unique within category ${metadataField}:`}
cancelTooltipContent="Close this dialog without editing label text."
primaryButtonText="Change label text"
text={editedLabelText}
categoryToDuplicate={null}
validationError={this.labelNameError(editedLabelText)}
errorMessage={this.labelNameErrorMessage(editedLabelText)}
handleSubmit={this.handleEditValue}
handleCancel={this.cancelEdit}
annoInput={
<AnnoInputs
useSuggest={ontologyEnabled}
text={editedLabelText}
handleCreateArbitraryLabel={
this.handleCreateArbitraryLabel
}
handleItemChange={this.handleSuggestActiveItemChange}
handleChoice={this.handleChoice}
handleTextChange={this.handleTextChange}
isTextInvalid={this.labelNameError}
isTextInvalidErrorMessage={this.labelNameErrorMessage}
/>
}
annoSelect={null}
/>
{this.valueNameErrorMessage()}
</form>
</div>
) : null}
{/*
CANCEL IT, WITH BUTTON, ESCAPE KEY, CLICK OUT, UNDO?
<Button
minimal
style={{ position: "relative", top: -1 }}
type="button"
icon="cross"
data-testclass="submitEdit"
data-testid="submitEdit"
onClick={this.cancelEdit}
/> */}
</div>
<span style={{ flexShrink: 0 }}>
{colorAccessor && !isColorBy && !annotations.isEditingLabelName ? (
@@ -483,113 +468,115 @@ class CategoryValue extends React.Component {
) : null}
</span>
</div>
<span>
<span
data-testclass="categorical-value-count"
data-testid={`categorical-value-count-${metadataField}-${displayString}`}
style={{
color:
displayString === globals.unassignedCategoryLabel
? "#ababab"
: "black",
fontStyle:
displayString === globals.unassignedCategoryLabel
? "italic"
: "auto"
}}
>
{count}
</span>
<svg
display={isColorBy && categories ? "auto" : "none"}
style={{
marginLeft: 5,
width: 11,
height: 11,
backgroundColor:
isColorBy && categories
? colorScale(categories.indexOf(value))
: "inherit"
}}
/>
{isUserAnno ? (
<div>
<span>
<span
onMouseEnter={this.handleMouseExit}
onMouseLeave={this.handleMouseEnter}
data-testclass="categorical-value-count"
data-testid={`categorical-value-count-${metadataField}-${displayString}`}
style={{
color:
displayString === globals.unassignedCategoryLabel
? "#ababab"
: "black",
fontStyle:
displayString === globals.unassignedCategoryLabel
? "italic"
: "auto"
}}
>
<Popover
interactionKind={PopoverInteractionKind.HOVER}
boundary="window"
position={Position.RIGHT_TOP}
content={
<Menu>
<MenuItem
icon="plus"
data-testclass="handleAddCurrentSelectionToThisLabel"
data-testid={`handleAddCurrentSelectionToThisLabel-${metadataField}`}
onClick={this.handleAddCurrentSelectionToThisLabel}
text={
<span>
Re-label currently selected cells as
<span
style={{
fontStyle:
displayString ===
globals.unassignedCategoryLabel
? "italic"
: "auto"
}}
>
{` ${displayString}`}
</span>
</span>
}
disabled={this.isAddCurrentSelectionDisabled(
metadataField,
value
)}
/>
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="edit"
text="Edit this label's name"
data-testclass="handleEditValue"
data-testid={`handleEditValue-${metadataField}`}
onClick={this.activateEditLabelMode}
disabled={annotations.isEditingLabelName}
/>
) : null}
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="delete"
intent="danger"
data-testclass="handleDeleteValue"
data-testid={`handleDeleteValue-${metadataField}`}
onClick={this.handleDeleteValue}
text={`Delete this label, and reassign all cells to type '${globals.unassignedCategoryLabel}'`}
/>
) : null}
</Menu>
}
>
<Button
style={{
marginLeft: 0,
position: "relative",
top: -1,
minHeight: 16
}}
data-testclass="seeActions"
data-testid={`seeActions-${metadataField}`}
icon="more"
small
minimal
/>
</Popover>
{count}
</span>
) : null}
</span>
<svg
display={isColorBy && categories ? "auto" : "none"}
style={{
marginLeft: 5,
width: 11,
height: 11,
backgroundColor:
isColorBy && categories
? colorScale(categories.indexOf(value))
: "inherit"
}}
/>
{isUserAnno ? (
<span
onMouseEnter={this.handleMouseExit}
onMouseLeave={this.handleMouseEnter}
>
<Popover
interactionKind={PopoverInteractionKind.HOVER}
boundary="window"
position={Position.RIGHT_TOP}
content={
<Menu>
<MenuItem
icon="plus"
data-testclass="handleAddCurrentSelectionToThisLabel"
data-testid={`handleAddCurrentSelectionToThisLabel-${metadataField}`}
onClick={this.handleAddCurrentSelectionToThisLabel}
text={
<span>
Re-label currently selected cells as
<span
style={{
fontStyle:
displayString ===
globals.unassignedCategoryLabel
? "italic"
: "auto"
}}
>
{` ${displayString}`}
</span>
</span>
}
disabled={this.isAddCurrentSelectionDisabled(
metadataField,
value
)}
/>
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="edit"
text="Edit this label's name"
data-testclass="handleEditValue"
data-testid={`handleEditValue-${metadataField}`}
onClick={this.activateEditLabelMode}
disabled={annotations.isEditingLabelName}
/>
) : null}
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="delete"
intent="danger"
data-testclass="handleDeleteValue"
data-testid={`handleDeleteValue-${metadataField}`}
onClick={this.handleDeleteValue}
text={`Delete this label, and reassign all cells to type '${globals.unassignedCategoryLabel}'`}
/>
) : null}
</Menu>
}
>
<Button
style={{
marginLeft: 0,
position: "relative",
top: -1,
minHeight: 16
}}
data-testclass="seeActions"
data-testid={`seeActions-${metadataField}`}
icon="more"
small
minimal
/>
</Popover>
</span>
) : null}
</span>
</div>
</div>
);
}
-1
View File
@@ -31,7 +31,6 @@ const Annotations = (
) => {
switch (action.type) {
case "configuration load complete": {
const DefaultDataCollectionName = null;
const dataCollectionName =
action.config.parameters?.["annotations-data-collection-name"] ?? null;
const dataCollectionNameIsReadOnly =
+7
View File
@@ -165,9 +165,16 @@ const CrossfilterReducerBase = (
return state.delDimension(obsAnnoDimensionName(action.metadataField));
}
case "annotation: add new label to category":
case "annotation: label current cell selection":
case "annotation: label edited":
case "annotation: delete label": {
if (
action.type === "annotation: add new label to category" &&
!action.assignSelectedCells
)
return state;
/* we need to reindex the dimension. For now, just drop it and add another */
const name = action.metadataField;
const dimName = obsAnnoDimensionName(name);
+5 -3
View File
@@ -18,6 +18,7 @@ import controls from "./controls";
import resetCache from "./resetCache";
import annotations from "./annotations";
import autosave from "./autosave";
import ontology from "./ontology";
import centroidLabels from "./centroidLabels";
import pointDialation from "./pointDilation";
@@ -28,6 +29,7 @@ const Reducer = undoable(
["config", config],
["universe", universe],
["world", world],
["ontology", ontology],
["annotations", annotations],
["layoutChoice", layoutChoice],
["categoricalSelection", categoricalSelection],
@@ -45,15 +47,15 @@ const Reducer = undoable(
]),
[
"universe",
"categoricalSelection",
"world",
"categoricalSelection",
"continuousSelection",
"graphSelection",
"crossfilter",
"layoutChoice",
"colors",
"controls",
"differential",
"colors",
"layoutChoice",
"centroidLabels",
"annotations"
],
+29
View File
@@ -0,0 +1,29 @@
// jshint esversion: 6
const Ontology = (
state = {
enabled: false, // are ontology terms enabled?
terms: null, // an array of term names, eg, ['cell', 'lung cell', ...]
termSet: null, // a Set object containing all terms, for fast lookup
loading: true
},
action
) => {
switch (action.type) {
case "configuration load complete":
const enabled =
action.config?.parameters?.annotations_cell_ontology_enabled ?? false;
const terms = action.config?.parameters?.annotations_cell_ontology_terms;
const termSet = new Set(terms);
return {
...state,
loading: false,
enabled,
terms,
termSet
};
default:
return state;
}
};
export default Ontology;
+55 -18
View File
@@ -136,13 +136,27 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
"user annotations require a non-zero length string name"
);
/* add the new label to the annotation */
/* add the new label to the annotation schema */
const schema = AnnotationsHelpers.addObsAnnoCategory(
state.schema,
annotationName,
newLabelName
);
return { ...state, schema };
/* if so requested, label the current selection */
const { world, crossfilter } = prevSharedState;
const { metadataField, newLabelText } = action;
const obsAnnotations = !action.assignSelectedCells
? state.obsAnnotations
: setLabelOnCurrentSelection(
state,
world,
crossfilter,
metadataField,
newLabelText
);
return { ...state, schema, obsAnnotations };
}
case "annotation: label edited": {
@@ -208,23 +222,11 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
case "annotation: label current cell selection": {
const { metadataField, label } = action;
const { world, crossfilter } = prevSharedState;
/*
selection state is relative to world. We need to convert it
to a mask for Universe before applying it.
*/
const worldMask = crossfilter.allSelectedMask();
const mask = World.worldEqUniverse(world, state)
? worldMask
: AnnotationsHelpers.worldToUniverseMask(
worldMask,
world.obsAnnotations,
state.nObs
);
const obsAnnotations = AnnotationsHelpers.setLabelByMask(
state.obsAnnotations,
const obsAnnotations = setLabelOnCurrentSelection(
state,
world,
crossfilter,
metadataField,
mask,
label
);
return { ...state, obsAnnotations };
@@ -236,4 +238,39 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
}
};
function setLabelOnCurrentSelection(
universe,
world,
crossfilter,
metadataField,
label
) {
/*
Set category `metadataField` to value `label` for anything currently selected.
Used by several action type reducers.
Returns the new obsAnnotations dataframe.
*/
/*
selection state is relative to world. We need to convert it
to a mask for Universe before applying it.
*/
const worldMask = crossfilter.allSelectedMask();
const mask = World.worldEqUniverse(world, universe)
? worldMask
: AnnotationsHelpers.worldToUniverseMask(
worldMask,
world.obsAnnotations,
universe.nObs
);
const obsAnnotations = AnnotationsHelpers.setLabelByMask(
universe.obsAnnotations,
metadataField,
mask,
label
);
return obsAnnotations;
}
export default Universe;
+41 -14
View File
@@ -196,6 +196,21 @@ const WorldReducer = (
case "annotation: add new label to category": {
/* add a new label to the schema - schema updated by universe reducer, we just need to note it */
const { schema } = nextSharedState.universe;
const { metadataField, newLabelText } = action;
const { crossfilter } = prevSharedState;
if (action.assignSelectedCells) {
return {
...state,
schema,
...setLabelOnCurrentSelection(
state,
crossfilter,
metadataField,
newLabelText
)
};
}
return { ...state, schema };
}
@@ -246,21 +261,10 @@ const WorldReducer = (
case "annotation: label current cell selection": {
const { metadataField, label } = action;
const { crossfilter } = prevSharedState;
const mask = crossfilter.allSelectedMask();
const unclipped = {
...state.unclipped,
obsAnnotations: AnnotationsHelpers.setLabelByMask(
state.unclipped.obsAnnotations,
metadataField,
mask,
label
)
return {
...state,
...setLabelOnCurrentSelection(state, crossfilter, metadataField, label)
};
const obsAnnotations = state.obsAnnotations.replaceColData(
metadataField,
unclipped.obsAnnotations.col(metadataField).asArray()
);
return { ...state, obsAnnotations, unclipped };
}
default: {
@@ -269,4 +273,27 @@ const WorldReducer = (
}
};
function setLabelOnCurrentSelection(world, crossfilter, metadataField, label) {
/*
Set category `metadataField` to value `label` for anything currently selected.
Used by several action type reducers.
*/
const mask = crossfilter.allSelectedMask();
const unclipped = {
...world.unclipped,
obsAnnotations: AnnotationsHelpers.setLabelByMask(
world.unclipped.obsAnnotations,
metadataField,
mask,
label
)
};
const obsAnnotations = world.obsAnnotations.replaceColData(
metadataField,
unclipped.obsAnnotations.col(metadataField).asArray()
);
return { obsAnnotations, unclipped };
}
export default WorldReducer;
@@ -183,7 +183,7 @@ export function createWritableAnnotationDimensions(world, crossfilter) {
return crossfilter;
}
const legalCharacters = /^(\w|[ .])+$/;
const legalCharacters = /^(\w|[ .()-])+$/;
export function annotationNameIsErroneous(name) {
/*
Validate the name - return:
@@ -193,10 +193,9 @@ export function annotationNameIsErroneous(name) {
Tests:
0. must be string, non-null
1. no leading or trailing spaces
2. only accept alpha, numeric, underscore, period and space
2. only accept alpha, numeric, underscore, period, parens, hyphen and space
3. no runs of multiple spaces
*/
if (name === "") {
return "empty-string";
}