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>
);
}