Separate ontologies dialogue (#1126)

* componetization

* Ontologies as own menu option

* remove log

* remove dead suggest module
This commit is contained in:
Colin Megill
2020-01-27 15:10:57 -05:00
committed by GitHub
parent 8b8a800453
commit 9bdcf44564
10 changed files with 779 additions and 530 deletions
@@ -26,6 +26,7 @@ class AnnoDialog extends React.Component {
errorMessage,
validationError,
annoSelect,
ontologySelect,
annoInput,
handleCancel,
handleSubmit,
@@ -56,6 +57,7 @@ class AnnoDialog extends React.Component {
</p>
</div>
{annoSelect || null}
{ontologySelect || null}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
@@ -0,0 +1,133 @@
import React from "react";
import { connect } from "react-redux";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
}))
class Category extends React.Component {
constructor(props) {
super(props);
this.state = {
newLabelText: ""
};
}
disableAddNewLabelMode = () => {
const { dispatch } = this.props;
dispatch({
type: "annotation: disable add new label mode"
});
this.setState({
newLabelText: ""
});
};
handleAddNewLabelToCategory = () => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
dispatch({
type: "annotation: add new label to category",
metadataField,
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: "" });
};
labelNameError = name => {
const { metadataField, ontology, universe } = this.props;
return isLabelErroneous(name, metadataField, ontology, universe.schema);
};
labelNameErrorMessage = name => {
const { metadataField, ontology, universe } = this.props;
return labelErrorMessage(name, metadataField, ontology, universe.schema);
};
/* leaky to have both of these in multiple components */
handleChoice = e => {
this.setState({ newLabelText: e.target });
};
handleTextChange = text => {
this.setState({ newLabelText: text });
};
render() {
const { newLabelText } = this.state;
const { metadataField, annotations, ontologyEnabled } = this.props;
return (
<>
<AnnoDialog
isActive={
annotations.isAddingNewLabel &&
annotations.categoryAddingNewLabel === metadataField
}
title="Add new label to category"
instruction="New, unique label name:"
cancelTooltipContent="Close this dialog without adding a label."
primaryButtonText="Add label"
secondaryButtonText="Add label and 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}
/>
}
/>
</>
);
}
}
export default Category;
@@ -0,0 +1,131 @@
import React from "react";
import { connect } from "react-redux";
import AnnoDialog from "./annoDialog";
import OntologySelect from "./ontologySelect";
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
}))
class Category extends React.Component {
constructor(props) {
super(props);
this.state = {
newLabelText: ""
};
}
disableAddNewLabelFromOntologyMode = () => {
const { dispatch } = this.props;
dispatch({
type: "annotation: disable add new ontology label mode"
});
this.setState({
newLabelText: ""
});
};
handleAddNewLabelToCategory = () => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
dispatch({
type: "annotation: add new label to category",
metadataField,
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: "" });
};
labelNameError = name => {
const { metadataField, ontology, universe } = this.props;
return isLabelErroneous(name, metadataField, ontology, universe.schema);
};
labelNameErrorMessage = name => {
const { metadataField, ontology, universe } = this.props;
return labelErrorMessage(name, metadataField, ontology, universe.schema);
};
/* leaky to have both of these in multiple components */
handleChoice = e => {
this.setState({ newLabelText: e.target });
};
handleTextChange = text => {
this.setState({ newLabelText: text });
};
render() {
const { newLabelText } = this.state;
const { metadataField, annotations, ontology } = this.props;
return (
<>
<AnnoDialog
isActive={
annotations.isAddingNewLabelFromOntology &&
annotations.categoryAddingNewLabelFromOntology === metadataField
}
title="Add new label to category from existing ontology terms"
instruction="Choose an ontology term to use as a label name:"
cancelTooltipContent="Close this dialog without adding a label."
primaryButtonText="Add label"
secondaryButtonText="Add label and assign currently selected cells"
handleSecondaryButtonSubmit={this.addLabelAndAssignCells}
text={newLabelText}
validationError={this.labelNameError(newLabelText)}
errorMessage={this.labelNameErrorMessage(newLabelText)}
handleSubmit={this.handleAddNewLabelToCategory}
handleCancel={this.disableAddNewLabelFromOntologyMode}
ontologySelect={
<OntologySelect
handleChooseOntologyTermFromDropdown={term => {
this.handleChoice(term);
}}
categoryToDuplicate={newLabelText}
ontology={ontology}
/>
}
annoInput={null}
/>
</>
);
}
}
export default Category;
@@ -0,0 +1,146 @@
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { Colors } from "@blueprintjs/core";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import { AnnotationsHelpers } from "../../util/stateManager";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
}))
class AnnoDialogEditCategoryName extends React.Component {
constructor(props) {
super(props);
this.state = {
newCategoryText: props.metadataField
};
}
handleCategoryEditTextChange = txt => {
this.setState({
newCategoryText: txt
});
};
disableEditCategoryMode = () => {
const { dispatch } = this.props;
dispatch({
type: "annotation: disable category edit mode"
});
};
handleEditCategory = () => {
const { dispatch, metadataField, categoricalSelection } = this.props;
const { newCategoryText } = this.state;
const allCategoryNames = _.keys(categoricalSelection);
if (
(allCategoryNames.indexOf(newCategoryText) > -1 &&
newCategoryText !== metadataField) ||
newCategoryText === ""
) {
return;
}
dispatch({
type: "annotation: category edited",
metadataField,
newCategoryText,
data: newCategoryText
});
};
categoryNameErrorMessage = () => {
const err = this.editedCategoryNameError();
if (err === false) return null;
const errorMessageMap = {
/* map error code to human readable error message */
"empty-string": "Blank names not allowed",
duplicate: "Category 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
style={{
display: "block",
fontStyle: "italic",
fontSize: 12,
marginTop: 5,
color: Colors.ORANGE3
}}
>
{errorMessage}
</span>
);
};
editedCategoryNameError = () => {
const { metadataField, categoricalSelection } = this.props;
const { newCategoryText } = this.state;
/* check for syntax errors in category name */
const error = AnnotationsHelpers.annotationNameIsErroneous(newCategoryText);
if (error) {
return error;
}
/* check for duplicative categories */
const allCategoryNames = _.keys(categoricalSelection);
const categoryNameAlreadyExists =
allCategoryNames.indexOf(newCategoryText) > -1;
const sameName = newCategoryText === metadataField;
if (categoryNameAlreadyExists && !sameName) {
return "duplicate";
}
/* otherwise, no error */
return false;
};
render() {
const { newCategoryText } = this.state;
const { metadataField, annotations } = this.props;
return (
<>
<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}
/>
}
/>
</>
);
}
}
export default AnnoDialogEditCategoryName;
+3 -119
View File
@@ -1,94 +1,6 @@
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);
}}
/>
);
};
import { InputGroup } from "@blueprintjs/core";
const VanillaInput = props => {
const { text, handleTextChange } = props;
@@ -119,38 +31,10 @@ class AnnoInputs extends React.Component {
}
render() {
const {
useSuggest,
handleTextChange,
text,
ontologyLoading,
world,
handleChoice,
handleItemChange,
handleCreateArbitraryLabel,
ontology,
isTextInvalid,
isTextInvalidErrorMessage
} = this.props;
const { handleTextChange, text } = 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} />
)}
<VanillaInput text={text} handleTextChange={handleTextChange} />
</div>
);
}
@@ -0,0 +1,125 @@
import React from "react";
import { connect } from "react-redux";
import {
Button,
Menu,
MenuItem,
Popover,
Position,
PopoverInteractionKind
} from "@blueprintjs/core";
@connect(state => ({
annotations: state.annotations,
ontologyEnabled: state.ontology?.enabled
}))
class AnnoMenuCategory extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
activateAddNewLabelMode = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: activate add new label mode",
data: metadataField
});
};
activateAddNewOntologyLabelMode = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: activate add new ontology label mode",
data: metadataField
});
};
activateEditCategoryMode = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: activate category edit mode",
data: metadataField
});
};
handleDeleteCategory = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: delete category",
metadataField
});
};
render() {
const {
metadataField,
annotations,
isUserAnno,
ontologyEnabled,
createText,
createFromOntologyText,
editText,
deleteText
} = this.props;
return (
<>
{isUserAnno ? (
<Popover
interactionKind={PopoverInteractionKind.HOVER}
boundary="window"
position={Position.RIGHT_TOP}
content={
<Menu>
<MenuItem
icon="tag"
data-testclass="handleAddNewLabelToCategory"
data-testid={`handleAddNewLabelToCategory-${metadataField}`}
onClick={this.activateAddNewLabelMode}
text={createText}
/>
{ontologyEnabled ? (
<MenuItem
icon="book"
data-testclass="activateAddNewOntologyLabelMode"
data-testid={`activateAddNewOntologyLabelMode-${metadataField}`}
onClick={this.activateAddNewOntologyLabelMode}
text={createFromOntologyText}
/>
) : null}
<MenuItem
icon="edit"
disabled={annotations.isEditingCategoryName}
data-testclass="activateEditCategoryMode"
data-testid={`activateEditCategoryMode-${metadataField}`}
onClick={this.activateEditCategoryMode}
text={editText}
/>
<MenuItem
icon="delete"
intent="danger"
data-testclass="handleDeleteCategory"
data-testid={`handleDeleteCategory-${metadataField}`}
onClick={this.handleDeleteCategory}
text={deleteText}
/>
</Menu>
}
>
<Button
style={{ marginLeft: 0 }}
data-testclass="seeActions"
data-testid={`seeActions-${metadataField}`}
icon="more"
minimal
/>
</Popover>
) : null}
</>
);
}
}
export default AnnoMenuCategory;
+81 -410
View File
@@ -2,25 +2,14 @@ import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
import { Flipper, Flipped } from "react-flip-toolkit";
import {
Button,
Tooltip,
Menu,
MenuItem,
Popover,
Icon,
Position,
PopoverInteractionKind,
Colors
} from "@blueprintjs/core";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import { Button, Tooltip, Icon } from "@blueprintjs/core";
import CategoryFlipperLayout from "./categoryFlipperLayout";
import AnnoMenu from "./annoMenuCategory";
import AnnoDialogEditCategoryName from "./annoDialogEditCategoryName";
import AnnoDialogAddLabel from "./annoDialogAddLabel";
import AnnoDialogAddLabelFromOntology from "./annoDialogAddLabelFromOntology";
import * as globals from "../../globals";
import Value from "./value";
import { AnnotationsHelpers } from "../../util/stateManager";
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
@@ -36,9 +25,7 @@ class Category extends React.Component {
super(props);
this.state = {
isChecked: true,
isExpanded: false,
newCategoryText: props.metadataField,
newLabelText: ""
isExpanded: false
};
}
@@ -72,115 +59,6 @@ class Category extends React.Component {
}
}
activateAddNewLabelMode = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: activate add new label mode",
data: metadataField
});
};
disableAddNewLabelMode = () => {
const { dispatch } = this.props;
dispatch({
type: "annotation: disable add new label mode"
});
this.setState({
newLabelText: ""
});
};
handleAddNewLabelToCategory = () => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
dispatch({
type: "annotation: add new label to category",
metadataField,
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;
dispatch({
type: "annotation: activate category edit mode",
data: metadataField
});
};
disableEditCategoryMode = () => {
const { dispatch } = this.props;
dispatch({
type: "annotation: disable category edit mode"
});
};
handleEditCategory = () => {
const { dispatch, metadataField, categoricalSelection } = this.props;
const { newCategoryText } = this.state;
const allCategoryNames = _.keys(categoricalSelection);
if (
(allCategoryNames.indexOf(newCategoryText) > -1 &&
newCategoryText !== metadataField) ||
newCategoryText === ""
) {
return;
}
dispatch({
type: "annotation: category edited",
metadataField,
newCategoryText,
data: newCategoryText
});
};
handleDeleteCategory = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: delete category",
metadataField
});
};
handleColorChange = () => {
const { dispatch, metadataField } = this.props;
dispatch({
@@ -189,77 +67,6 @@ class Category extends React.Component {
});
};
labelNameError = name => {
const { metadataField, ontology, universe } = this.props;
return isLabelErroneous(name, metadataField, ontology, universe.schema);
};
labelNameErrorMessage = name => {
const { metadataField, ontology, universe } = this.props;
return labelErrorMessage(name, metadataField, ontology, universe.schema);
};
categoryNameErrorMessage = () => {
const err = this.editedCategoryNameError();
if (err === false) return null;
const errorMessageMap = {
/* map error code to human readable error message */
"empty-string": "Blank names not allowed",
duplicate: "Category 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
style={{
display: "block",
fontStyle: "italic",
fontSize: 12,
marginTop: 5,
color: Colors.ORANGE3
}}
>
{errorMessage}
</span>
);
};
editedCategoryNameError = () => {
const { metadataField, categoricalSelection } = this.props;
const { newCategoryText } = this.state;
/* check for syntax errors in category name */
const error = AnnotationsHelpers.annotationNameIsErroneous(newCategoryText);
if (error) {
return error;
}
/* check for duplicative categories */
const allCategoryNames = _.keys(categoricalSelection);
const categoryNameAlreadyExists =
allCategoryNames.indexOf(newCategoryText) > -1;
const sameName = newCategoryText === metadataField;
if (categoryNameAlreadyExists && !sameName) {
return "duplicate";
}
/* otherwise, no error */
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({
@@ -288,239 +95,103 @@ class Category extends React.Component {
}
}
renderCategoryItems(optTuples) {
const { metadataField, isUserAnno } = this.props;
return _.map(optTuples, (tuple, i) => {
return (
<Flipped key={tuple[1]} flipId={tuple[1]}>
{flippedProps => (
<Value
isUserAnno={isUserAnno}
optTuples={optTuples}
key={tuple[1]}
metadataField={metadataField}
categoryIndex={tuple[1]}
i={i}
flippedProps={flippedProps}
/>
)}
</Flipped>
);
});
}
render() {
const { isExpanded, isChecked, newLabelText, newCategoryText } = this.state;
const { isExpanded, isChecked } = this.state;
const {
metadataField,
colorAccessor,
categoricalSelection,
isUserAnno,
annotations,
ontologyEnabled
annotations
} = this.props;
const { isTruncated } = categoricalSelection[metadataField];
const cat = categoricalSelection[metadataField];
const optTuples = [...cat.categoryValueIndices];
const optTuplesAsKey = _.map(optTuples, t => t[0]).join(""); // animation
return (
<div
style={{
maxWidth: globals.maxControlsWidth
}}
data-testclass="category"
data-testid={`category-${metadataField}`}
<CategoryFlipperLayout
metadataField={metadataField}
isExpanded={isExpanded}
isUserAnno={isUserAnno}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline"
justifyContent: "flex-start",
alignItems: "flex-start"
}}
>
<div
<label className="bp3-control bp3-checkbox">
<input
data-testclass="category-select"
data-testid={`category-select-${metadataField}`}
onChange={this.handleToggleAllClick.bind(this)}
ref={el => {
this.checkbox = el;
return el;
}}
checked={isChecked}
type="checkbox"
/>
<span className="bp3-control-indicator" />
</label>
<span
data-testid={`category-expand-${metadataField}`}
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start"
cursor: "pointer",
display: "inline-block"
}}
onClick={() => {
const editingCategory =
annotations.isEditingCategoryName &&
annotations.categoryBeingEdited === metadataField;
if (!editingCategory) {
this.setState({ isExpanded: !isExpanded });
}
}}
>
<label className="bp3-control bp3-checkbox">
<input
data-testclass="category-select"
data-testid={`category-select-${metadataField}`}
onChange={this.handleToggleAllClick.bind(this)}
ref={el => {
this.checkbox = el;
return el;
}}
checked={isChecked}
type="checkbox"
/>
<span className="bp3-control-indicator" />
</label>
<span
data-testid={`category-expand-${metadataField}`}
style={{
cursor: "pointer",
display: "inline-block"
}}
onClick={() => {
const editingCategory =
annotations.isEditingCategoryName &&
annotations.categoryBeingEdited === metadataField;
if (!editingCategory) {
this.setState({ isExpanded: !isExpanded });
}
}}
>
{isUserAnno ? (
<Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} />
) : null}
{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}
/>
}
/>
{isExpanded ? (
<FaChevronDown
data-testclass="category-expand-is-expanded"
style={{ fontSize: 10, marginLeft: 5 }}
/>
) : (
<FaChevronRight
data-testclass="category-expand-is-not-expanded"
style={{ fontSize: 10, marginLeft: 5 }}
/>
)}
</span>
</div>
<div>
{isUserAnno ? (
<>
<AnnoDialog
isActive={
annotations.isAddingNewLabel &&
annotations.categoryAddingNewLabel === metadataField
}
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"
position={Position.RIGHT_TOP}
content={
<Menu>
<MenuItem
icon="tag"
data-testclass="handleAddNewLabelToCategory"
data-testid={`handleAddNewLabelToCategory-${metadataField}`}
onClick={this.activateAddNewLabelMode}
text="Add a new label to this category"
/>
<MenuItem
icon="edit"
disabled={annotations.isEditingCategoryName}
data-testclass="activateEditCategoryMode"
data-testid={`activateEditCategoryMode-${metadataField}`}
onClick={this.activateEditCategoryMode}
text="Edit this category's name"
/>
<MenuItem
icon="delete"
intent="danger"
data-testclass="handleDeleteCategory"
data-testid={`handleDeleteCategory-${metadataField}`}
onClick={this.handleDeleteCategory}
text="Delete this category, all associated labels, and remove all cell assignments"
/>
</Menu>
}
>
<Button
style={{ marginLeft: 0 }}
data-testclass="seeActions"
data-testid={`seeActions-${metadataField}`}
icon="more"
minimal
/>
</Popover>
</>
<Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} />
) : null}
<Tooltip
content="Use as color scale"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<Button
data-testclass="colorby"
data-testid={`colorby-${metadataField}`}
onClick={this.handleColorChange}
active={colorAccessor === metadataField}
intent={colorAccessor === metadataField ? "primary" : "none"}
icon="tint"
{metadataField}
{isExpanded ? (
<FaChevronDown
data-testclass="category-expand-is-expanded"
style={{ fontSize: 10, marginLeft: 5 }}
/>
</Tooltip>
</div>
</div>
<div style={{ marginLeft: 26 }}>
<Flipper spring="veryGentle" flipKey={optTuplesAsKey}>
{isExpanded ? this.renderCategoryItems(optTuples) : null}
</Flipper>
) : (
<FaChevronRight
data-testclass="category-expand-is-not-expanded"
style={{ fontSize: 10, marginLeft: 5 }}
/>
)}
</span>
</div>
{<AnnoDialogEditCategoryName metadataField={metadataField} />}
{<AnnoDialogAddLabel metadataField={metadataField} />}
{<AnnoDialogAddLabelFromOntology metadataField={metadataField} />}
<div>
{isExpanded && isTruncated ? (
<p style={{ paddingLeft: 15 }}>... truncated list ...</p>
) : null}
<AnnoMenu
metadataField={metadataField}
isUserAnno={isUserAnno}
createText="Add a new label to this category"
createFromOntologyText="Add a new label to this category using existing ontology terms"
editText="Edit this category's name"
deleteText="Delete this category, all associated labels, and remove all cell assignments"
/>
<Tooltip
content="Use as color scale"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<Button
data-testclass="colorby"
data-testid={`colorby-${metadataField}`}
onClick={this.handleColorChange}
active={colorAccessor === metadataField}
intent={colorAccessor === metadataField ? "primary" : "none"}
icon="tint"
/>
</Tooltip>
</div>
</div>
</CategoryFlipperLayout>
);
}
}
@@ -0,0 +1,84 @@
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { Flipper, Flipped } from "react-flip-toolkit";
import * as globals from "../../globals";
import Value from "./value";
@connect(state => ({
categoricalSelection: state.categoricalSelection
}))
class Category extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
renderCategoryItems(optTuples) {
const { metadataField, isUserAnno } = this.props;
return _.map(optTuples, (tuple, i) => {
return (
<Flipped key={tuple[1]} flipId={tuple[1]}>
{flippedProps => (
<Value
isUserAnno={isUserAnno}
optTuples={optTuples}
key={tuple[1]}
metadataField={metadataField}
categoryIndex={tuple[1]}
i={i}
flippedProps={flippedProps}
/>
)}
</Flipped>
);
});
}
render() {
const {
metadataField,
categoricalSelection,
children,
isExpanded
} = this.props;
const { isTruncated } = categoricalSelection[metadataField];
const cat = categoricalSelection[metadataField];
const optTuples = [...cat.categoryValueIndices];
const optTuplesAsKey = _.map(optTuples, t => t[0]).join(""); // animation
return (
<div
style={{
maxWidth: globals.maxControlsWidth
}}
data-testclass="category"
data-testid={`category-${metadataField}`}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline"
}}
>
{children}
</div>
<div style={{ marginLeft: 26 }}>
<Flipper spring="veryGentle" flipKey={optTuplesAsKey}>
{isExpanded ? this.renderCategoryItems(optTuples) : null}
</Flipper>
</div>
<div>
{isExpanded && isTruncated ? (
<p style={{ paddingLeft: 15 }}>... truncated list ...</p>
) : null}
</div>
</div>
);
}
}
export default Category;
@@ -0,0 +1,57 @@
import React from "react";
import { connect } from "react-redux";
import { Button, MenuItem } from "@blueprintjs/core";
import { Select } 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
});
@connect()
class ChooseOntologySelect extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
const {
ontology,
categoryToDuplicate,
handleChooseOntologyTermFromDropdown
} = this.props;
return (
<div>
<Select
items={
ontology?.terms ||
[] /* this is a placeholder, could be a subcomponent to avoid this */
}
filterable
itemListPredicate={filterOntology}
itemRenderer={(d, { handleClick }) => {
return (
<MenuItem onClick={handleClick} key={d.target} text={d.target} />
);
}}
noResults={<MenuItem disabled text="No results." />}
onItemSelect={d => {
handleChooseOntologyTermFromDropdown(d);
}}
>
{/* children become the popover target; render value here */}
<Button
text={categoryToDuplicate || "Choose an Ontology Term"}
rightIcon="double-caret-vertical"
/>
</Select>
</div>
);
}
}
export default ChooseOntologySelect;
+17 -1
View File
@@ -61,17 +61,33 @@ const Annotations = (
isAddingNewLabel: true,
categoryAddingNewLabel: action.data
};
case "annotation: activate add new ontology label mode":
return {
...state,
isAddingNewLabelFromOntology: true,
categoryAddingNewLabelFromOntology: action.data
};
case "annotation: disable add new ontology label mode":
return {
...state,
isAddingNewLabelFromOntology: false,
categoryAddingNewLabelFromOntology: null
};
case "annotation: disable add new label mode":
return {
...state,
isAddingNewLabel: false,
categoryAddingNewLabel: null
};
/* this handles both cases, since at this point it's just a string */
case "annotation: add new label to category":
return {
...state,
isAddingNewLabel: false,
categoryAddingNewLabel: null
categoryAddingNewLabel: null,
isAddingNewLabelFromOntology: false,
categoryAddingNewLabelFromOntology: null
};
case "annotation: activate category edit mode":
return {