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 (
-