mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-08 17:18:11 +08:00
Separate ontologies dialogue (#1126)
* componetization * Ontologies as own menu option * remove log * remove dead suggest module
This commit is contained in:
@@ -26,6 +26,7 @@ class AnnoDialog extends React.Component {
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
validationError,
|
validationError,
|
||||||
annoSelect,
|
annoSelect,
|
||||||
|
ontologySelect,
|
||||||
annoInput,
|
annoInput,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -56,6 +57,7 @@ class AnnoDialog extends React.Component {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{annoSelect || null}
|
{annoSelect || null}
|
||||||
|
{ontologySelect || null}
|
||||||
</div>
|
</div>
|
||||||
<div className={Classes.DIALOG_FOOTER}>
|
<div className={Classes.DIALOG_FOOTER}>
|
||||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
<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;
|
||||||
@@ -1,94 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { InputGroup, MenuItem } from "@blueprintjs/core";
|
import { InputGroup } 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 VanillaInput = props => {
|
||||||
const { text, handleTextChange } = props;
|
const { text, handleTextChange } = props;
|
||||||
@@ -119,38 +31,10 @@ class AnnoInputs extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const { handleTextChange, text } = this.props;
|
||||||
useSuggest,
|
|
||||||
handleTextChange,
|
|
||||||
text,
|
|
||||||
ontologyLoading,
|
|
||||||
world,
|
|
||||||
handleChoice,
|
|
||||||
handleItemChange,
|
|
||||||
handleCreateArbitraryLabel,
|
|
||||||
ontology,
|
|
||||||
isTextInvalid,
|
|
||||||
isTextInvalidErrorMessage
|
|
||||||
} = this.props;
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Le sigh https://github.com/palantir/blueprint/issues/2864 */}
|
<VanillaInput text={text} handleTextChange={handleTextChange} />
|
||||||
{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>
|
</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;
|
||||||
@@ -2,25 +2,14 @@ import React from "react";
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
||||||
import { Flipper, Flipped } from "react-flip-toolkit";
|
import { Button, Tooltip, Icon } from "@blueprintjs/core";
|
||||||
import {
|
import CategoryFlipperLayout from "./categoryFlipperLayout";
|
||||||
Button,
|
import AnnoMenu from "./annoMenuCategory";
|
||||||
Tooltip,
|
import AnnoDialogEditCategoryName from "./annoDialogEditCategoryName";
|
||||||
Menu,
|
import AnnoDialogAddLabel from "./annoDialogAddLabel";
|
||||||
MenuItem,
|
import AnnoDialogAddLabelFromOntology from "./annoDialogAddLabelFromOntology";
|
||||||
Popover,
|
|
||||||
Icon,
|
|
||||||
Position,
|
|
||||||
PopoverInteractionKind,
|
|
||||||
Colors
|
|
||||||
} from "@blueprintjs/core";
|
|
||||||
import AnnoDialog from "./annoDialog";
|
|
||||||
import AnnoInputs from "./annoInputs";
|
|
||||||
|
|
||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
import Value from "./value";
|
|
||||||
import { AnnotationsHelpers } from "../../util/stateManager";
|
|
||||||
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
|
|
||||||
|
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
colorAccessor: state.colors.colorAccessor,
|
colorAccessor: state.colors.colorAccessor,
|
||||||
@@ -36,9 +25,7 @@ class Category extends React.Component {
|
|||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
isChecked: true,
|
isChecked: true,
|
||||||
isExpanded: false,
|
isExpanded: false
|
||||||
newCategoryText: props.metadataField,
|
|
||||||
newLabelText: ""
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 = () => {
|
handleColorChange = () => {
|
||||||
const { dispatch, metadataField } = this.props;
|
const { dispatch, metadataField } = this.props;
|
||||||
dispatch({
|
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() {
|
toggleAll() {
|
||||||
const { dispatch, metadataField } = this.props;
|
const { dispatch, metadataField } = this.props;
|
||||||
dispatch({
|
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() {
|
render() {
|
||||||
const { isExpanded, isChecked, newLabelText, newCategoryText } = this.state;
|
const { isExpanded, isChecked } = this.state;
|
||||||
const {
|
const {
|
||||||
metadataField,
|
metadataField,
|
||||||
colorAccessor,
|
colorAccessor,
|
||||||
categoricalSelection,
|
|
||||||
isUserAnno,
|
isUserAnno,
|
||||||
annotations,
|
annotations
|
||||||
ontologyEnabled
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { isTruncated } = categoricalSelection[metadataField];
|
|
||||||
const cat = categoricalSelection[metadataField];
|
|
||||||
const optTuples = [...cat.categoryValueIndices];
|
|
||||||
const optTuplesAsKey = _.map(optTuples, t => t[0]).join(""); // animation
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CategoryFlipperLayout
|
||||||
style={{
|
metadataField={metadataField}
|
||||||
maxWidth: globals.maxControlsWidth
|
isExpanded={isExpanded}
|
||||||
}}
|
isUserAnno={isUserAnno}
|
||||||
data-testclass="category"
|
|
||||||
data-testid={`category-${metadataField}`}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "flex-start",
|
||||||
alignItems: "baseline"
|
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={{
|
style={{
|
||||||
display: "flex",
|
cursor: "pointer",
|
||||||
justifyContent: "flex-start",
|
display: "inline-block"
|
||||||
alignItems: "flex-start"
|
}}
|
||||||
|
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 ? (
|
{isUserAnno ? (
|
||||||
<>
|
<Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} />
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
<Tooltip
|
{metadataField}
|
||||||
content="Use as color scale"
|
{isExpanded ? (
|
||||||
position="bottom"
|
<FaChevronDown
|
||||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
data-testclass="category-expand-is-expanded"
|
||||||
>
|
style={{ fontSize: 10, marginLeft: 5 }}
|
||||||
<Button
|
|
||||||
data-testclass="colorby"
|
|
||||||
data-testid={`colorby-${metadataField}`}
|
|
||||||
onClick={this.handleColorChange}
|
|
||||||
active={colorAccessor === metadataField}
|
|
||||||
intent={colorAccessor === metadataField ? "primary" : "none"}
|
|
||||||
icon="tint"
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
) : (
|
||||||
</div>
|
<FaChevronRight
|
||||||
</div>
|
data-testclass="category-expand-is-not-expanded"
|
||||||
<div style={{ marginLeft: 26 }}>
|
style={{ fontSize: 10, marginLeft: 5 }}
|
||||||
<Flipper spring="veryGentle" flipKey={optTuplesAsKey}>
|
/>
|
||||||
{isExpanded ? this.renderCategoryItems(optTuples) : null}
|
)}
|
||||||
</Flipper>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{<AnnoDialogEditCategoryName metadataField={metadataField} />}
|
||||||
|
{<AnnoDialogAddLabel metadataField={metadataField} />}
|
||||||
|
{<AnnoDialogAddLabelFromOntology metadataField={metadataField} />}
|
||||||
<div>
|
<div>
|
||||||
{isExpanded && isTruncated ? (
|
<AnnoMenu
|
||||||
<p style={{ paddingLeft: 15 }}>... truncated list ...</p>
|
metadataField={metadataField}
|
||||||
) : null}
|
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>
|
||||||
</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;
|
||||||
@@ -61,17 +61,33 @@ const Annotations = (
|
|||||||
isAddingNewLabel: true,
|
isAddingNewLabel: true,
|
||||||
categoryAddingNewLabel: action.data
|
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":
|
case "annotation: disable add new label mode":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isAddingNewLabel: false,
|
isAddingNewLabel: false,
|
||||||
categoryAddingNewLabel: null
|
categoryAddingNewLabel: null
|
||||||
};
|
};
|
||||||
|
/* this handles both cases, since at this point it's just a string */
|
||||||
case "annotation: add new label to category":
|
case "annotation: add new label to category":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isAddingNewLabel: false,
|
isAddingNewLabel: false,
|
||||||
categoryAddingNewLabel: null
|
categoryAddingNewLabel: null,
|
||||||
|
isAddingNewLabelFromOntology: false,
|
||||||
|
categoryAddingNewLabelFromOntology: null
|
||||||
};
|
};
|
||||||
case "annotation: activate category edit mode":
|
case "annotation: activate category edit mode":
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user