diff --git a/client/src/components/categorical/annoDialog.js b/client/src/components/categorical/annoDialog.js index 2e66dc21..77e24f77 100644 --- a/client/src/components/categorical/annoDialog.js +++ b/client/src/components/categorical/annoDialog.js @@ -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 {

{annoSelect || null} + {ontologySelect || null}
diff --git a/client/src/components/categorical/annoDialogAddLabel.js b/client/src/components/categorical/annoDialogAddLabel.js new file mode 100644 index 00000000..98d3f932 --- /dev/null +++ b/client/src/components/categorical/annoDialogAddLabel.js @@ -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 ( + <> + + } + /> + + ); + } +} + +export default Category; diff --git a/client/src/components/categorical/annoDialogAddLabelFromOntology.js b/client/src/components/categorical/annoDialogAddLabelFromOntology.js new file mode 100644 index 00000000..1e4e359f --- /dev/null +++ b/client/src/components/categorical/annoDialogAddLabelFromOntology.js @@ -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 ( + <> + { + this.handleChoice(term); + }} + categoryToDuplicate={newLabelText} + ontology={ontology} + /> + } + annoInput={null} + /> + + ); + } +} + +export default Category; diff --git a/client/src/components/categorical/annoDialogEditCategoryName.js b/client/src/components/categorical/annoDialogEditCategoryName.js new file mode 100644 index 00000000..fd43ea9e --- /dev/null +++ b/client/src/components/categorical/annoDialogEditCategoryName.js @@ -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 ( + + {errorMessage} + + ); + }; + + 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 ( + <> + + } + /> + + ); + } +} + +export default AnnoDialogEditCategoryName; diff --git a/client/src/components/categorical/annoInputs.js b/client/src/components/categorical/annoInputs.js index 17d02fc8..e15ee312 100644 --- a/client/src/components/categorical/annoInputs.js +++ b/client/src/components/categorical/annoInputs.js @@ -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 ( - - /* 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 ( - {}} - createNewItemRenderer={userInputStr => { - return isTextInvalid?.(userInputStr) ? ( - - ) : ( - { - handleCreateArbitraryLabel(userInputStr); - }} - shouldDismissPopover={false} - /> - ); - }} - itemDisabled={ontologyLoading ? () => true : () => false} - noResults={} - onItemSelect={handleChoice} - initialContent={ - - } - 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 (
- {/* Le sigh https://github.com/palantir/blueprint/issues/2864 */} - {useSuggest ? ( - - ) : ( - - )} +
); } diff --git a/client/src/components/categorical/annoMenuCategory.js b/client/src/components/categorical/annoMenuCategory.js new file mode 100644 index 00000000..db2c3fd5 --- /dev/null +++ b/client/src/components/categorical/annoMenuCategory.js @@ -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 ? ( + + + {ontologyEnabled ? ( + + ) : null} + + + + } + > +
-
-
- - {isExpanded ? this.renderCategoryItems(optTuples) : null} - + ) : ( + + )} +
+ {} + {} + {}
- {isExpanded && isTruncated ? ( -

... truncated list ...

- ) : null} + + + +
- + ); } } diff --git a/client/src/components/categorical/categoryFlipperLayout.js b/client/src/components/categorical/categoryFlipperLayout.js new file mode 100644 index 00000000..87fcfd62 --- /dev/null +++ b/client/src/components/categorical/categoryFlipperLayout.js @@ -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 ( + + {flippedProps => ( + + )} + + ); + }); + } + + 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 ( +
+
+ {children} +
+
+ + {isExpanded ? this.renderCategoryItems(optTuples) : null} + +
+
+ {isExpanded && isTruncated ? ( +

... truncated list ...

+ ) : null} +
+
+ ); + } +} + +export default Category; diff --git a/client/src/components/categorical/ontologySelect.js b/client/src/components/categorical/ontologySelect.js new file mode 100644 index 00000000..7e82615a --- /dev/null +++ b/client/src/components/categorical/ontologySelect.js @@ -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 ( +
+ +
+ ); + } +} + +export default ChooseOntologySelect; diff --git a/client/src/reducers/annotations.js b/client/src/reducers/annotations.js index 82955260..8db16997 100644 --- a/client/src/reducers/annotations.js +++ b/client/src/reducers/annotations.js @@ -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 {