mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-26 05:28:12 +08:00
Ontologies (#1110)
* add sample ontologies file * add ontologies reducer * Move select category to own component * Dialog and Input factored out * refactoring categorical, partway * validationn * anno * suggest populates input * frontend for ontology working * initial implementation of back-end support for ontologies * edit is now dialog again * autosuggest working on edit * part way through create arbitrary label * handle choice in function * pass duplicate cat prop * editing works * update test to match new CLI params * fix occupancy alignment * edit category as dialogue * secondary button * remove stubbed out ontologies * add label setting upon new label creation * Update legal characters for labels (#1119) * Allow any term in the ontology (bypass legal name check) * Add hyphens and parens to legal characters in names * improve performance for large ontologies * correctly handle case where ontologies are disabled * fix logic error in CLI Co-authored-by: Bruce Martin <bruce@chanzuckerberg.com> * PR cleanup 1 * lint * validate user generated labels * finish hooking up connected suggest component * protect against undefined callbacks * Fix illegal characters error message * break out npm run commands * fix error detection on label edit Co-authored-by: Bruce Martin <bruce@chanzuckerberg.com> Co-authored-by: Sidney Bell <sidneymbell@users.noreply.github.com>
This commit is contained in:
co-authored by
Bruce Martin
Sidney Bell
parent
8d725b1ad9
commit
d48647a655
@@ -0,0 +1,91 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Button, Tooltip, Dialog, Classes, Colors } from "@blueprintjs/core";
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
universe: state.universe,
|
||||
ontology: state.ontology,
|
||||
ontologyLoading: state.ontology?.loading
|
||||
}))
|
||||
class AnnoDialog extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
isActive,
|
||||
text,
|
||||
title,
|
||||
instruction,
|
||||
cancelTooltipContent,
|
||||
errorMessage,
|
||||
validationError,
|
||||
annoSelect,
|
||||
annoInput,
|
||||
handleCancel,
|
||||
handleSubmit,
|
||||
primaryButtonText,
|
||||
secondaryButtonText,
|
||||
handleSecondaryButtonSubmit
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Dialog icon="tag" title={title} isOpen={isActive} onClose={handleCancel}>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<p>{instruction}</p>
|
||||
{annoInput || null}
|
||||
<p
|
||||
style={{
|
||||
marginTop: 7,
|
||||
visibility: validationError ? "visible" : "hidden",
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
</div>
|
||||
{annoSelect || null}
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Tooltip content={cancelTooltipContent}>
|
||||
<Button onClick={handleCancel}>Cancel</Button>
|
||||
</Tooltip>
|
||||
{handleSecondaryButtonSubmit && secondaryButtonText ? (
|
||||
<Button
|
||||
onClick={handleSecondaryButtonSubmit}
|
||||
disabled={!text || validationError}
|
||||
intent="none"
|
||||
type="submit"
|
||||
>
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!text || validationError}
|
||||
intent="primary"
|
||||
type="submit"
|
||||
>
|
||||
{primaryButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AnnoDialog;
|
||||
@@ -0,0 +1,159 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { InputGroup, MenuItem } from "@blueprintjs/core";
|
||||
import { Suggest } from "@blueprintjs/select";
|
||||
import fuzzysort from "fuzzysort";
|
||||
|
||||
const filterOntology = (query, ontology) =>
|
||||
/* fires on load, once, and then for each character typed into the input */
|
||||
fuzzysort.go(query, ontology, {
|
||||
limit: 100,
|
||||
threshold: -10000 // don't return bad results
|
||||
});
|
||||
|
||||
const renderListItem = (fuzzySortResult, { handleClick, modifiers }) => {
|
||||
if (!modifiers.matchesPredicate) {
|
||||
return null;
|
||||
}
|
||||
/* the fuzzysort wraps the object with other properties, like a score */
|
||||
const geneName = fuzzySortResult.target;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
active={modifiers.active}
|
||||
disabled={modifiers.disabled}
|
||||
data-testid={`suggest-menu-item-${geneName}`}
|
||||
// Use of annotations in this way is incorrect and dataset specific.
|
||||
// See https://github.com/chanzuckerberg/cellxgene/issues/483
|
||||
// label={gene.n_counts}
|
||||
key={geneName}
|
||||
onClick={g =>
|
||||
/* this fires when user clicks a menu item */
|
||||
handleClick(g)
|
||||
}
|
||||
text={geneName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AnnoSuggest = props => {
|
||||
const {
|
||||
ontologyLoading,
|
||||
handleItemChange,
|
||||
handleChoice,
|
||||
ontology,
|
||||
handleCreateArbitraryLabel,
|
||||
handleTextChange,
|
||||
isTextInvalid,
|
||||
isTextInvalidErrorMessage
|
||||
} = props;
|
||||
return (
|
||||
<Suggest
|
||||
fill
|
||||
resetOnSelect
|
||||
closeOnSelect
|
||||
resetOnClose
|
||||
createNewItemFromQuery={() => {}}
|
||||
createNewItemRenderer={userInputStr => {
|
||||
return isTextInvalid?.(userInputStr) ? (
|
||||
<MenuItem disabled text={isTextInvalidErrorMessage(userInputStr)} />
|
||||
) : (
|
||||
<MenuItem
|
||||
icon="add"
|
||||
text="Create a label not in the ontology"
|
||||
active
|
||||
onClick={() => {
|
||||
handleCreateArbitraryLabel(userInputStr);
|
||||
}}
|
||||
shouldDismissPopover={false}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
itemDisabled={ontologyLoading ? () => true : () => false}
|
||||
noResults={<MenuItem disabled text="No matching ontology identifier" />}
|
||||
onItemSelect={handleChoice}
|
||||
initialContent={
|
||||
<MenuItem disabled text="Enter an ontology identifier…" />
|
||||
}
|
||||
inputProps={{ "data-testid": "gene-search" }}
|
||||
inputValueRenderer={t => t.target}
|
||||
itemListPredicate={filterOntology}
|
||||
onActiveItemChange={handleItemChange}
|
||||
itemRenderer={renderListItem.bind(this)}
|
||||
items={!ontologyLoading && ontology ? ontology : ["No ontology loaded"]}
|
||||
popoverProps={{ minimal: true }}
|
||||
onQueryChange={(s, e) => {
|
||||
// undefined event means resetOnSelect
|
||||
if (e !== undefined) handleTextChange?.(s);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const VanillaInput = props => {
|
||||
const { text, handleTextChange } = props;
|
||||
return (
|
||||
<InputGroup
|
||||
autoFocus
|
||||
value={text}
|
||||
intent="none"
|
||||
onChange={e => handleTextChange?.(e.target.value)}
|
||||
leftIcon="tag"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
universe: state.universe,
|
||||
world: state.world,
|
||||
ontology: state.ontology?.terms,
|
||||
ontologyLoading: state.ontology?.loading
|
||||
}))
|
||||
class AnnoInputs extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
useSuggest,
|
||||
handleTextChange,
|
||||
text,
|
||||
ontologyLoading,
|
||||
world,
|
||||
handleChoice,
|
||||
handleItemChange,
|
||||
handleCreateArbitraryLabel,
|
||||
ontology,
|
||||
isTextInvalid,
|
||||
isTextInvalidErrorMessage
|
||||
} = this.props;
|
||||
return (
|
||||
<div>
|
||||
{/* Le sigh https://github.com/palantir/blueprint/issues/2864 */}
|
||||
{useSuggest ? (
|
||||
<AnnoSuggest
|
||||
world={world}
|
||||
text={text}
|
||||
handleChoice={handleChoice}
|
||||
ontologyLoading={ontologyLoading}
|
||||
handleCreateArbitraryLabel={handleCreateArbitraryLabel}
|
||||
handleItemChange={handleItemChange}
|
||||
ontology={ontology}
|
||||
handleTextChange={handleTextChange}
|
||||
isTextInvalid={isTextInvalid}
|
||||
isTextInvalidErrorMessage={isTextInvalidErrorMessage}
|
||||
/>
|
||||
) : (
|
||||
<VanillaInput text={text} handleTextChange={handleTextChange} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AnnoInputs;
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Button, MenuItem } from "@blueprintjs/core";
|
||||
import { Select } from "@blueprintjs/select";
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
universe: state.universe,
|
||||
ontology: state.ontology,
|
||||
ontologyLoading: state.ontology?.loading
|
||||
}))
|
||||
class DuplicateCategorySelect extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
allCategoryNames,
|
||||
categoryToDuplicate,
|
||||
handleModalDuplicateCategorySelection
|
||||
} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
Optionally duplicate all labels & cell assignments from existing
|
||||
category into new category:
|
||||
</p>
|
||||
<Select
|
||||
items={
|
||||
allCategoryNames ||
|
||||
[] /* this is a placeholder, could be a subcomponent to avoid this */
|
||||
}
|
||||
filterable={false}
|
||||
itemRenderer={(d, { handleClick }) => {
|
||||
return <MenuItem onClick={handleClick} key={d} text={d} />;
|
||||
}}
|
||||
noResults={<MenuItem disabled text="No results." />}
|
||||
onItemSelect={d => {
|
||||
handleModalDuplicateCategorySelection(d);
|
||||
}}
|
||||
>
|
||||
{/* children become the popover target; render value here */}
|
||||
<Button
|
||||
text={categoryToDuplicate || "None (all cells 'unassigned')"}
|
||||
rightIcon="double-caret-vertical"
|
||||
/>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DuplicateCategorySelect;
|
||||
@@ -1,20 +1,13 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
InputGroup,
|
||||
Dialog,
|
||||
Classes,
|
||||
MenuItem,
|
||||
Colors
|
||||
} from "@blueprintjs/core";
|
||||
import { Select } from "@blueprintjs/select";
|
||||
import { Button } from "@blueprintjs/core";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import Category from "./category";
|
||||
import { AnnotationsHelpers } from "../../util/stateManager";
|
||||
import AnnoDialog from "./annoDialog";
|
||||
import AnnoInputs from "./annoInputs";
|
||||
import AnnoSelect from "./annoSelect";
|
||||
|
||||
@connect(state => ({
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
@@ -71,7 +64,7 @@ class Categories extends React.Component {
|
||||
/* allow empty string */
|
||||
if (name === "") return false;
|
||||
|
||||
/*
|
||||
/*
|
||||
test for uniqueness against *all* annotation names, not just the subset
|
||||
we render as categorical.
|
||||
*/
|
||||
@@ -102,13 +95,25 @@ class Categories extends React.Component {
|
||||
"empty-string": "Blank names not allowed",
|
||||
duplicate: "Name must be unique",
|
||||
"trim-spaces": "Leading and trailing spaces not allowed",
|
||||
"illegal-characters": "Only alphanumeric, underscore and period allowed",
|
||||
"illegal-characters":
|
||||
"Only alphanumeric and special characters (-_.) allowed",
|
||||
"multi-space-run": "Multiple consecutive spaces not allowed"
|
||||
};
|
||||
const errorMessage = errorMessageMap[err] ?? "error";
|
||||
return <span>{errorMessage}</span>;
|
||||
};
|
||||
|
||||
handleNewCategoryText = txt => {
|
||||
this.setState({ newCategoryText: txt });
|
||||
};
|
||||
|
||||
handleChoice = e => {
|
||||
/* Blueprint Suggest format */
|
||||
this.setState({ newCategoryText: e.target });
|
||||
};
|
||||
|
||||
handleSuggestActiveItemChange = () => {};
|
||||
|
||||
render() {
|
||||
const {
|
||||
createAnnoModeActive,
|
||||
@@ -131,9 +136,39 @@ class Categories extends React.Component {
|
||||
padding: globals.leftSidebarSectionPadding
|
||||
}}
|
||||
>
|
||||
<AnnoDialog
|
||||
isActive={createAnnoModeActive}
|
||||
title="Create new category"
|
||||
instruction="New, unique category name:"
|
||||
cancelTooltipContent="Close this dialog without creating a category."
|
||||
primaryButtonText="Create new category"
|
||||
text={newCategoryText}
|
||||
validationError={this.categoryNameError(newCategoryText)}
|
||||
errorMessage={this.categoryNameErrorMessage(newCategoryText)}
|
||||
handleSubmit={this.handleCreateUserAnno}
|
||||
handleCancel={this.handleDisableAnnoMode}
|
||||
annoInput={
|
||||
<AnnoInputs
|
||||
text={newCategoryText}
|
||||
handleItemChange={this.handleSuggestActiveItemChange}
|
||||
handleChoice={this.handleChoice}
|
||||
handleTextChange={this.handleNewCategoryText}
|
||||
/>
|
||||
}
|
||||
annoSelect={
|
||||
<AnnoSelect
|
||||
handleModalDuplicateCategorySelection={
|
||||
this.handleModalDuplicateCategorySelection
|
||||
}
|
||||
categoryToDuplicate={categoryToDuplicate}
|
||||
allCategoryNames={allCategoryNames}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* READ ONLY CATEGORICAL FIELDS */}
|
||||
{/* this is duplicative but flat, could be abstracted */}
|
||||
{_.map(allCategoryNames, catName =>
|
||||
{allCategoryNames.map(catName =>
|
||||
!schema.annotations.obsByName[catName].writable ? (
|
||||
<Category
|
||||
key={catName}
|
||||
@@ -144,7 +179,7 @@ class Categories extends React.Component {
|
||||
) : null
|
||||
)}
|
||||
{/* WRITEABLE FIELDS */}
|
||||
{_.map(allCategoryNames, catName =>
|
||||
{allCategoryNames.map(catName =>
|
||||
schema.annotations.obsByName[catName].writable ? (
|
||||
<Category
|
||||
key={catName}
|
||||
@@ -156,94 +191,6 @@ class Categories extends React.Component {
|
||||
)}
|
||||
{writableCategoriesEnabled ? (
|
||||
<div>
|
||||
<Dialog
|
||||
icon="tag"
|
||||
title="Create new category"
|
||||
isOpen={createAnnoModeActive}
|
||||
onClose={this.handleDisableAnnoMode}
|
||||
>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<p>New, unique category name:</p>
|
||||
<InputGroup
|
||||
autoFocus
|
||||
value={newCategoryText}
|
||||
intent={
|
||||
this.categoryNameError(newCategoryText)
|
||||
? "warning"
|
||||
: "none"
|
||||
}
|
||||
onChange={e =>
|
||||
this.setState({ newCategoryText: e.target.value })
|
||||
}
|
||||
leftIcon="tag"
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 7,
|
||||
visibility: this.categoryNameError(newCategoryText)
|
||||
? "visible"
|
||||
: "hidden",
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{this.categoryNameErrorMessage(newCategoryText)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Optionally duplicate all labels & cell assignments from
|
||||
existing category into new category:
|
||||
</p>
|
||||
<Select
|
||||
items={allCategoryNames}
|
||||
filterable={false}
|
||||
itemRenderer={(d, { handleClick }) => {
|
||||
return (
|
||||
<MenuItem onClick={handleClick} key={d} text={d} />
|
||||
);
|
||||
}}
|
||||
noResults={<MenuItem disabled text="No results." />}
|
||||
onItemSelect={d => {
|
||||
this.handleModalDuplicateCategorySelection(d);
|
||||
}}
|
||||
>
|
||||
{/* children become the popover target; render value here */}
|
||||
<Button
|
||||
text={
|
||||
categoryToDuplicate || "None (all cells 'unassigned')"
|
||||
}
|
||||
rightIcon="double-caret-vertical"
|
||||
/>
|
||||
</Select>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Tooltip content="Close this dialog without creating a category.">
|
||||
<Button onClick={this.handleDisableAnnoMode}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
onClick={this.handleCreateUserAnno}
|
||||
disabled={
|
||||
!newCategoryText ||
|
||||
this.categoryNameError(newCategoryText)
|
||||
}
|
||||
intent="primary"
|
||||
type="submit"
|
||||
>
|
||||
Create new category
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
<Button onClick={this.handleEnableAnnoMode} intent="primary">
|
||||
Create new category
|
||||
</Button>
|
||||
|
||||
@@ -6,28 +6,30 @@ import { Flipper, Flipped } from "react-flip-toolkit";
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
InputGroup,
|
||||
Menu,
|
||||
Dialog,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Classes,
|
||||
Icon,
|
||||
Position,
|
||||
PopoverInteractionKind,
|
||||
Colors
|
||||
} from "@blueprintjs/core";
|
||||
import AnnoDialog from "./annoDialog";
|
||||
import AnnoInputs from "./annoInputs";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import Value from "./value";
|
||||
import sortedCategoryLabels from "../../util/catLabelSort";
|
||||
import { AnnotationsHelpers } from "../../util/stateManager";
|
||||
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
universe: state.universe
|
||||
universe: state.universe,
|
||||
ontology: state.ontology,
|
||||
ontologyLoading: state.ontology?.loading,
|
||||
ontologyEnabled: state.ontology?.enabled
|
||||
}))
|
||||
class Category extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -91,14 +93,48 @@ class Category extends React.Component {
|
||||
handleAddNewLabelToCategory = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
const { newLabelText } = this.state;
|
||||
|
||||
dispatch({
|
||||
type: "annotation: add new label to category",
|
||||
metadataField,
|
||||
newLabelText
|
||||
newLabelText,
|
||||
assignSelectedCells: false
|
||||
});
|
||||
this.setState({ newLabelText: "" });
|
||||
};
|
||||
|
||||
addLabelAndAssignCells = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
const { newLabelText } = this.state;
|
||||
|
||||
dispatch({
|
||||
type: "annotation: add new label to category",
|
||||
metadataField,
|
||||
newLabelText,
|
||||
assignSelectedCells: true
|
||||
});
|
||||
|
||||
this.setState({ newLabelText: "" });
|
||||
};
|
||||
|
||||
handleCreateArbitraryLabel = newLabelTextNotInOntology => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
|
||||
dispatch({
|
||||
type: "annotation: add new label to category",
|
||||
metadataField,
|
||||
newLabelText: newLabelTextNotInOntology,
|
||||
assignSelectedCells: false
|
||||
});
|
||||
this.setState({ newLabelText: "" });
|
||||
};
|
||||
|
||||
handleCategoryEditTextChange = txt => {
|
||||
this.setState({
|
||||
newCategoryText: txt
|
||||
});
|
||||
};
|
||||
|
||||
activateEditCategoryMode = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
|
||||
@@ -154,63 +190,16 @@ class Category extends React.Component {
|
||||
};
|
||||
|
||||
labelNameError = name => {
|
||||
/*
|
||||
return false if this is a LEGAL/acceptable category name or NULL/empty string,
|
||||
or return an error type.
|
||||
*/
|
||||
|
||||
/* allow empty string */
|
||||
if (name === "") return false;
|
||||
|
||||
/* check for label syntax errors */
|
||||
const error = AnnotationsHelpers.annotationNameIsErroneous(name);
|
||||
if (error) return error;
|
||||
|
||||
/* disallow duplicates */
|
||||
const { metadataField, universe } = this.props;
|
||||
const { obsByName } = universe.schema.annotations;
|
||||
if (obsByName[metadataField].categories.indexOf(name) !== -1)
|
||||
return "duplicate";
|
||||
|
||||
/* otherwise, no error */
|
||||
return false;
|
||||
const { metadataField, ontology, universe } = this.props;
|
||||
return isLabelErroneous(name, metadataField, ontology, universe.schema);
|
||||
};
|
||||
|
||||
labelNameErrorMessage = name => {
|
||||
const { metadataField } = this.props;
|
||||
const err = this.labelNameError(name);
|
||||
|
||||
if (err === "duplicate") {
|
||||
/* duplicate error is special cased because it has special formatting */
|
||||
return (
|
||||
<span>
|
||||
<span style={{ fontStyle: "italic" }}>{name}</span> already exists
|
||||
already exists within{" "}
|
||||
<span style={{ fontStyle: "italic" }}>{metadataField}</span>{" "}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (err) {
|
||||
/* all other errors - map code to human error message */
|
||||
const errorMessageMap = {
|
||||
"empty-string": "Blank names not allowed",
|
||||
duplicate: "Name must be unique",
|
||||
"trim-spaces": "Leading and trailing spaces not allowed",
|
||||
"illegal-characters":
|
||||
"Only alphanumeric, underscore and period allowed",
|
||||
"multi-space-run": "Multiple consecutive spaces not allowed"
|
||||
};
|
||||
const errorMessage = errorMessageMap[err] ?? "error";
|
||||
return <span>{errorMessage}</span>;
|
||||
}
|
||||
|
||||
/* no error, no message generated */
|
||||
return null;
|
||||
const { metadataField, ontology, universe } = this.props;
|
||||
return labelErrorMessage(name, metadataField, ontology, universe.schema);
|
||||
};
|
||||
|
||||
categoryNameErrorMessage = () => {
|
||||
const { newCategoryText } = this.state;
|
||||
const err = this.editedCategoryNameError();
|
||||
if (err === false) return null;
|
||||
|
||||
@@ -219,7 +208,8 @@ class Category extends React.Component {
|
||||
"empty-string": "Blank names not allowed",
|
||||
duplicate: "Category name must be unique",
|
||||
"trim-spaces": "Leading and trailing spaces not allowed",
|
||||
"illegal-characters": "Only alphanumeric, underscore and period allowed",
|
||||
"illegal-characters":
|
||||
"Only alphanumeric and special characters (-_.) allowed",
|
||||
"multi-space-run": "Multiple consecutive spaces not allowed"
|
||||
};
|
||||
const errorMessage = errorMessageMap[err] ?? "error";
|
||||
@@ -261,6 +251,15 @@ class Category extends React.Component {
|
||||
return false;
|
||||
};
|
||||
|
||||
/* leaky to have both of these in multiple components */
|
||||
handleChoice = e => {
|
||||
this.setState({ newLabelText: e.target });
|
||||
};
|
||||
|
||||
handleTextChange = text => {
|
||||
this.setState({ newLabelText: text });
|
||||
};
|
||||
|
||||
toggleAll() {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
@@ -318,13 +317,13 @@ class Category extends React.Component {
|
||||
colorAccessor,
|
||||
categoricalSelection,
|
||||
isUserAnno,
|
||||
annotations
|
||||
annotations,
|
||||
ontologyEnabled
|
||||
} = this.props;
|
||||
const { isTruncated } = categoricalSelection[metadataField];
|
||||
const cat = categoricalSelection[metadataField];
|
||||
const optTuples = [...cat.categoryValueIndices];
|
||||
const optTuplesAsKey = _.map(optTuples, t => t[0]).join(""); // animation
|
||||
const allCategoryNames = _.keys(categoricalSelection);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -381,46 +380,30 @@ class Category extends React.Component {
|
||||
<Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} />
|
||||
) : null}
|
||||
|
||||
{annotations.isEditingCategoryName &&
|
||||
annotations.categoryBeingEdited === metadataField ? (
|
||||
<form
|
||||
style={{ display: "inline-block" }}
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
this.handleEditCategory();
|
||||
}}
|
||||
>
|
||||
<InputGroup
|
||||
style={{ position: "relative", top: -1 }}
|
||||
ref={input => {
|
||||
this.editableCategoryInput = input;
|
||||
}}
|
||||
small
|
||||
autoFocus
|
||||
onChange={e => {
|
||||
this.setState({
|
||||
newCategoryText: e.target.value
|
||||
});
|
||||
}}
|
||||
defaultValue={metadataField}
|
||||
rightElement={
|
||||
<Button
|
||||
minimal
|
||||
disabled={this.editedCategoryNameError()}
|
||||
style={{ position: "relative", top: -1 }}
|
||||
type="button"
|
||||
icon="small-tick"
|
||||
data-testclass="submitCategoryNameEdit"
|
||||
data-testid="submitCategoryNameEdit"
|
||||
onClick={this.handleEditCategory}
|
||||
/>
|
||||
}
|
||||
{metadataField}
|
||||
|
||||
<AnnoDialog
|
||||
isActive={
|
||||
annotations.isEditingCategoryName &&
|
||||
annotations.categoryBeingEdited === metadataField
|
||||
}
|
||||
title="Edit category name"
|
||||
instruction="New, unique category name:"
|
||||
cancelTooltipContent="Close this dialog without editing this category."
|
||||
primaryButtonText="Edit category name"
|
||||
text={newCategoryText}
|
||||
validationError={this.editedCategoryNameError(newCategoryText)}
|
||||
errorMessage={this.categoryNameErrorMessage(newCategoryText)}
|
||||
handleSubmit={this.handleEditCategory}
|
||||
handleCancel={this.disableEditCategoryMode}
|
||||
annoInput={
|
||||
<AnnoInputs
|
||||
useSuggest={false}
|
||||
text={newCategoryText}
|
||||
handleTextChange={this.handleCategoryEditTextChange}
|
||||
/>
|
||||
{this.categoryNameErrorMessage()}
|
||||
</form>
|
||||
) : (
|
||||
metadataField
|
||||
)}
|
||||
}
|
||||
/>
|
||||
|
||||
{isExpanded ? (
|
||||
<FaChevronDown
|
||||
@@ -438,71 +421,37 @@ class Category extends React.Component {
|
||||
<div>
|
||||
{isUserAnno ? (
|
||||
<>
|
||||
<Dialog
|
||||
icon="tag"
|
||||
title="Add new label"
|
||||
isOpen={
|
||||
<AnnoDialog
|
||||
isActive={
|
||||
annotations.isAddingNewLabel &&
|
||||
annotations.categoryAddingNewLabel === metadataField
|
||||
}
|
||||
onClose={this.disableAddNewLabelMode}
|
||||
>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
this.handleAddNewLabelToCategory();
|
||||
}}
|
||||
>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<p>New, unique label name:</p>
|
||||
<InputGroup
|
||||
autoFocus
|
||||
value={newLabelText}
|
||||
intent={
|
||||
this.labelNameError(newLabelText)
|
||||
? "warning"
|
||||
: "none"
|
||||
}
|
||||
onChange={e =>
|
||||
this.setState({ newLabelText: e.target.value })
|
||||
}
|
||||
leftIcon="tag"
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 7,
|
||||
visibility: this.labelNameError(newLabelText)
|
||||
? "visible"
|
||||
: "hidden",
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{this.labelNameErrorMessage(newLabelText)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Tooltip content="Close this dialog without adding a label.">
|
||||
<Button onClick={this.disableAddNewLabelMode}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
disabled={
|
||||
!newLabelText || this.labelNameError(newLabelText)
|
||||
}
|
||||
onClick={this.handleAddNewLabelToCategory}
|
||||
intent="primary"
|
||||
type="submit"
|
||||
>
|
||||
Add new label to category
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
title="Add new label to category"
|
||||
instruction="New, unique label name:"
|
||||
cancelTooltipContent="Close this dialog without adding a label."
|
||||
primaryButtonText="Add"
|
||||
secondaryButtonText="Add label & assign currently selected cells"
|
||||
handleSecondaryButtonSubmit={this.addLabelAndAssignCells}
|
||||
text={newLabelText}
|
||||
validationError={this.labelNameError(newLabelText)}
|
||||
errorMessage={this.labelNameErrorMessage(newLabelText)}
|
||||
handleSubmit={this.handleAddNewLabelToCategory}
|
||||
handleCancel={this.disableAddNewLabelMode}
|
||||
annoInput={
|
||||
<AnnoInputs
|
||||
useSuggest={ontologyEnabled}
|
||||
text={newLabelText}
|
||||
handleCreateArbitraryLabel={
|
||||
this.handleCreateArbitraryLabel
|
||||
}
|
||||
handleItemChange={this.handleSuggestActiveItemChange}
|
||||
handleChoice={this.handleChoice}
|
||||
handleTextChange={this.handleTextChange}
|
||||
isTextInvalid={this.labelNameError}
|
||||
isTextInvalidErrorMessage={this.labelNameErrorMessage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Popover
|
||||
interactionKind={PopoverInteractionKind.HOVER}
|
||||
boundary="window"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { AnnotationsHelpers } from "../../util/stateManager";
|
||||
|
||||
export function isLabelErroneous(label, metadataField, ontology, schema) {
|
||||
/*
|
||||
return false if this is a LEGAL/acceptable category name or NULL/empty string,
|
||||
or return an error type.
|
||||
*/
|
||||
|
||||
/* allow empty string */
|
||||
if (label === "") return false;
|
||||
|
||||
/* check for label syntax errors, but allow terms in ontology */
|
||||
const termInOntology = ontology?.termSet.has(label) ?? false;
|
||||
const error = AnnotationsHelpers.annotationNameIsErroneous(label);
|
||||
if (error && !termInOntology) return error;
|
||||
|
||||
/* disallow duplicates */
|
||||
const { obsByName } = schema.annotations;
|
||||
if (obsByName[metadataField].categories.indexOf(label) !== -1)
|
||||
return "duplicate";
|
||||
|
||||
/* otherwise, no error */
|
||||
return false;
|
||||
}
|
||||
|
||||
export function labelErrorMessage(label, metadataField, ontology, schema) {
|
||||
const err = isLabelErroneous(label, metadataField, ontology, schema);
|
||||
|
||||
if (err === "duplicate") {
|
||||
/* duplicate error is special cased because it has special formatting */
|
||||
return (
|
||||
<span>
|
||||
<span style={{ fontStyle: "italic" }}>{label}</span> already
|
||||
exists already exists within{" "}
|
||||
<span style={{ fontStyle: "italic" }}>{metadataField}</span>{" "}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (err) {
|
||||
/* all other errors - map code to human error message */
|
||||
const errorMessageMap = {
|
||||
"empty-string": "Blank names not allowed",
|
||||
duplicate: "Name must be unique",
|
||||
"trim-spaces": "Leading and trailing spaces not allowed",
|
||||
"illegal-characters":
|
||||
"Only alphanumeric and special characters (-_.) allowed",
|
||||
"multi-space-run": "Multiple consecutive spaces not allowed"
|
||||
};
|
||||
const errorMessage = errorMessageMap[err] ?? "error";
|
||||
return <span>{errorMessage}</span>;
|
||||
}
|
||||
|
||||
/* no error, no message generated */
|
||||
return null;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import React from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
InputGroup,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Popover,
|
||||
@@ -16,8 +15,11 @@ import {
|
||||
import Occupancy from "./occupancy";
|
||||
import * as globals from "../../globals";
|
||||
import styles from "./categorical.css";
|
||||
import AnnoDialog from "./annoDialog";
|
||||
import AnnoInputs from "./annoInputs";
|
||||
|
||||
import { AnnotationsHelpers } from "../../util/stateManager";
|
||||
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
|
||||
|
||||
@connect(state => ({
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
@@ -27,7 +29,10 @@ import { AnnotationsHelpers } from "../../util/stateManager";
|
||||
pointDilation: state.pointDilation,
|
||||
schema: state.world?.schema,
|
||||
world: state.world,
|
||||
crossfilter: state.crossfilter
|
||||
crossfilter: state.crossfilter,
|
||||
ontology: state.ontology,
|
||||
ontologyLoading: state.ontology?.loading,
|
||||
ontologyEnabled: state.ontology?.enabled
|
||||
}))
|
||||
class CategoryValue extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -41,13 +46,20 @@ class CategoryValue extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
getLabel = () => {
|
||||
const { metadataField, categoryIndex, categoricalSelection } = this.props;
|
||||
const category = categoricalSelection[metadataField];
|
||||
const label = category.categoryValues[categoryIndex];
|
||||
|
||||
return label;
|
||||
};
|
||||
componentDidUpdate(prevProps) {
|
||||
const { categoricalSelection, metadataField, categoryIndex } = this.props;
|
||||
if (
|
||||
prevProps.categoricalSelection !== categoricalSelection ||
|
||||
prevProps.metadataField !== metadataField ||
|
||||
prevProps.categoryIndex !== categoryIndex
|
||||
) {
|
||||
this.setState({
|
||||
editedLabelText: String(
|
||||
categoricalSelection[metadataField].categoryValues[categoryIndex]
|
||||
).valueOf()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleDeleteValue = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
@@ -84,60 +96,49 @@ class CategoryValue extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
valueNameErrorMessage = () => {
|
||||
const err = this.valueNameError();
|
||||
if (err === false) return null;
|
||||
handleCreateArbitraryLabel = editedLabelTextNotInOntology => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
const label = this.getLabel();
|
||||
|
||||
const errorMessageMap = {
|
||||
/* map error code to human readable error message */
|
||||
"empty-string": "Blank names not allowed",
|
||||
duplicate: "Label must be unique",
|
||||
"trim-spaces": "Leading and trailing spaces not allowed",
|
||||
"illegal-characters": "Only alphanumeric, underscore and period allowed",
|
||||
"multi-space-run": "Multiple consecutive spaces not allowed"
|
||||
};
|
||||
const errorMessage = errorMessageMap[err] ?? "error";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontStyle: "italic",
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{errorMessage}
|
||||
</span>
|
||||
);
|
||||
dispatch({
|
||||
type: "annotation: label edited",
|
||||
metadataField,
|
||||
editedLabel: editedLabelTextNotInOntology,
|
||||
categoryIndex,
|
||||
label
|
||||
});
|
||||
};
|
||||
|
||||
valueNameError = () => {
|
||||
const { editedLabelText } = this.state;
|
||||
const { categoricalSelection, metadataField, categoryIndex } = this.props;
|
||||
|
||||
/*
|
||||
check label syntax
|
||||
*/
|
||||
const err = AnnotationsHelpers.annotationNameIsErroneous(editedLabelText);
|
||||
if (err) return err;
|
||||
|
||||
/*
|
||||
disallow duplicates
|
||||
*/
|
||||
labelNameError = name => {
|
||||
const {
|
||||
metadataField,
|
||||
ontology,
|
||||
schema,
|
||||
categoricalSelection,
|
||||
categoryIndex
|
||||
} = this.props;
|
||||
const category = categoricalSelection[metadataField];
|
||||
const displayString = String(
|
||||
category.categoryValues[categoryIndex]
|
||||
).valueOf();
|
||||
if (
|
||||
category.categoryValues.indexOf(editedLabelText) > -1 &&
|
||||
editedLabelText !== displayString
|
||||
)
|
||||
return "duplicate";
|
||||
if (name === displayString) return false;
|
||||
return isLabelErroneous(name, metadataField, ontology, schema);
|
||||
};
|
||||
|
||||
/*
|
||||
otherwise, all good!
|
||||
*/
|
||||
return false;
|
||||
labelNameErrorMessage = name => {
|
||||
const {
|
||||
metadataField,
|
||||
ontology,
|
||||
schema,
|
||||
categoricalSelection,
|
||||
categoryIndex
|
||||
} = this.props;
|
||||
const category = categoricalSelection[metadataField];
|
||||
const displayString = String(
|
||||
category.categoryValues[categoryIndex]
|
||||
).valueOf();
|
||||
if (name === displayString) return null;
|
||||
return labelErrorMessage(name, metadataField, ontology, schema);
|
||||
};
|
||||
|
||||
activateEditLabelMode = () => {
|
||||
@@ -208,21 +209,6 @@ class CategoryValue extends React.Component {
|
||||
);
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { categoricalSelection, metadataField, categoryIndex } = this.props;
|
||||
if (
|
||||
prevProps.categoricalSelection !== categoricalSelection ||
|
||||
prevProps.metadataField !== metadataField ||
|
||||
prevProps.categoryIndex !== categoryIndex
|
||||
) {
|
||||
this.setState({
|
||||
editedLabelText: String(
|
||||
categoricalSelection[metadataField].categoryValues[categoryIndex]
|
||||
).valueOf()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleOn = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
dispatch({
|
||||
@@ -250,6 +236,23 @@ class CategoryValue extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
handleTextChange = text => {
|
||||
this.setState({ editedLabelText: text });
|
||||
};
|
||||
|
||||
handleChoice = e => {
|
||||
/* Blueprint Suggest format */
|
||||
this.setState({ editedLabelText: e.target });
|
||||
};
|
||||
|
||||
getLabel = () => {
|
||||
const { metadataField, categoryIndex, categoricalSelection } = this.props;
|
||||
const category = categoricalSelection[metadataField];
|
||||
const label = category.categoryValues[categoryIndex];
|
||||
|
||||
return label;
|
||||
};
|
||||
|
||||
isAddCurrentSelectionDisabled(category, value) {
|
||||
/*
|
||||
disable "add current selection to label", if one of the following is true:
|
||||
@@ -289,6 +292,7 @@ class CategoryValue extends React.Component {
|
||||
schema,
|
||||
isUserAnno,
|
||||
annotations,
|
||||
ontologyEnabled,
|
||||
// flippedProps is potentially brittle, their docs want {...flippedProps} on our div,
|
||||
// our lint doesn't like jsx spread, we are version pinned to prevent api change on their part
|
||||
flippedProps,
|
||||
@@ -339,6 +343,12 @@ class CategoryValue extends React.Component {
|
||||
)}`;
|
||||
}
|
||||
|
||||
const editModeActive =
|
||||
isUserAnno &&
|
||||
annotations.labelEditable.category === metadataField &&
|
||||
annotations.isEditingLabelName &&
|
||||
annotations.labelEditable.label === categoryIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -416,66 +426,41 @@ class CategoryValue extends React.Component {
|
||||
verticalAlign: "middle"
|
||||
}}
|
||||
>
|
||||
{annotations.isEditingLabelName &&
|
||||
annotations.labelEditable.category === metadataField &&
|
||||
annotations.labelEditable.label === categoryIndex
|
||||
? null
|
||||
: truncatedString || displayString}
|
||||
{truncatedString || displayString}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{isUserAnno &&
|
||||
annotations.labelEditable.category === metadataField &&
|
||||
annotations.isEditingLabelName &&
|
||||
annotations.labelEditable.label === categoryIndex ? (
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
if (this.valueNameError()) {
|
||||
return;
|
||||
}
|
||||
this.handleEditValue();
|
||||
}}
|
||||
>
|
||||
<InputGroup
|
||||
style={{ position: "relative", top: -1 }}
|
||||
ref={input => {
|
||||
this.editableInput = input;
|
||||
}}
|
||||
small
|
||||
autoFocus
|
||||
intent={this.valueNameError() ? "warning" : "none"}
|
||||
onChange={e => {
|
||||
this.setState({ editedLabelText: e.target.value });
|
||||
}}
|
||||
defaultValue={displayString}
|
||||
rightElement={
|
||||
<Button
|
||||
minimal
|
||||
style={{ position: "relative", top: -1 }}
|
||||
disabled={this.valueNameError()}
|
||||
type="button"
|
||||
icon="small-tick"
|
||||
data-testclass="submitEdit"
|
||||
data-testid="submitEdit"
|
||||
onClick={this.handleEditValue}
|
||||
{editModeActive ? (
|
||||
<div>
|
||||
<AnnoDialog
|
||||
isActive={editModeActive}
|
||||
title="Edit label"
|
||||
instruction={`New label text must be unique within category ${metadataField}:`}
|
||||
cancelTooltipContent="Close this dialog without editing label text."
|
||||
primaryButtonText="Change label text"
|
||||
text={editedLabelText}
|
||||
categoryToDuplicate={null}
|
||||
validationError={this.labelNameError(editedLabelText)}
|
||||
errorMessage={this.labelNameErrorMessage(editedLabelText)}
|
||||
handleSubmit={this.handleEditValue}
|
||||
handleCancel={this.cancelEdit}
|
||||
annoInput={
|
||||
<AnnoInputs
|
||||
useSuggest={ontologyEnabled}
|
||||
text={editedLabelText}
|
||||
handleCreateArbitraryLabel={
|
||||
this.handleCreateArbitraryLabel
|
||||
}
|
||||
handleItemChange={this.handleSuggestActiveItemChange}
|
||||
handleChoice={this.handleChoice}
|
||||
handleTextChange={this.handleTextChange}
|
||||
isTextInvalid={this.labelNameError}
|
||||
isTextInvalidErrorMessage={this.labelNameErrorMessage}
|
||||
/>
|
||||
}
|
||||
annoSelect={null}
|
||||
/>
|
||||
{this.valueNameErrorMessage()}
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
{/*
|
||||
CANCEL IT, WITH BUTTON, ESCAPE KEY, CLICK OUT, UNDO?
|
||||
|
||||
<Button
|
||||
minimal
|
||||
style={{ position: "relative", top: -1 }}
|
||||
type="button"
|
||||
icon="cross"
|
||||
data-testclass="submitEdit"
|
||||
data-testid="submitEdit"
|
||||
onClick={this.cancelEdit}
|
||||
/> */}
|
||||
</div>
|
||||
<span style={{ flexShrink: 0 }}>
|
||||
{colorAccessor && !isColorBy && !annotations.isEditingLabelName ? (
|
||||
@@ -483,113 +468,115 @@ class CategoryValue extends React.Component {
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<span
|
||||
data-testclass="categorical-value-count"
|
||||
data-testid={`categorical-value-count-${metadataField}-${displayString}`}
|
||||
style={{
|
||||
color:
|
||||
displayString === globals.unassignedCategoryLabel
|
||||
? "#ababab"
|
||||
: "black",
|
||||
fontStyle:
|
||||
displayString === globals.unassignedCategoryLabel
|
||||
? "italic"
|
||||
: "auto"
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
|
||||
<svg
|
||||
display={isColorBy && categories ? "auto" : "none"}
|
||||
style={{
|
||||
marginLeft: 5,
|
||||
width: 11,
|
||||
height: 11,
|
||||
backgroundColor:
|
||||
isColorBy && categories
|
||||
? colorScale(categories.indexOf(value))
|
||||
: "inherit"
|
||||
}}
|
||||
/>
|
||||
{isUserAnno ? (
|
||||
<div>
|
||||
<span>
|
||||
<span
|
||||
onMouseEnter={this.handleMouseExit}
|
||||
onMouseLeave={this.handleMouseEnter}
|
||||
data-testclass="categorical-value-count"
|
||||
data-testid={`categorical-value-count-${metadataField}-${displayString}`}
|
||||
style={{
|
||||
color:
|
||||
displayString === globals.unassignedCategoryLabel
|
||||
? "#ababab"
|
||||
: "black",
|
||||
fontStyle:
|
||||
displayString === globals.unassignedCategoryLabel
|
||||
? "italic"
|
||||
: "auto"
|
||||
}}
|
||||
>
|
||||
<Popover
|
||||
interactionKind={PopoverInteractionKind.HOVER}
|
||||
boundary="window"
|
||||
position={Position.RIGHT_TOP}
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon="plus"
|
||||
data-testclass="handleAddCurrentSelectionToThisLabel"
|
||||
data-testid={`handleAddCurrentSelectionToThisLabel-${metadataField}`}
|
||||
onClick={this.handleAddCurrentSelectionToThisLabel}
|
||||
text={
|
||||
<span>
|
||||
Re-label currently selected cells as
|
||||
<span
|
||||
style={{
|
||||
fontStyle:
|
||||
displayString ===
|
||||
globals.unassignedCategoryLabel
|
||||
? "italic"
|
||||
: "auto"
|
||||
}}
|
||||
>
|
||||
{` ${displayString}`}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
disabled={this.isAddCurrentSelectionDisabled(
|
||||
metadataField,
|
||||
value
|
||||
)}
|
||||
/>
|
||||
{displayString !== globals.unassignedCategoryLabel ? (
|
||||
<MenuItem
|
||||
icon="edit"
|
||||
text="Edit this label's name"
|
||||
data-testclass="handleEditValue"
|
||||
data-testid={`handleEditValue-${metadataField}`}
|
||||
onClick={this.activateEditLabelMode}
|
||||
disabled={annotations.isEditingLabelName}
|
||||
/>
|
||||
) : null}
|
||||
{displayString !== globals.unassignedCategoryLabel ? (
|
||||
<MenuItem
|
||||
icon="delete"
|
||||
intent="danger"
|
||||
data-testclass="handleDeleteValue"
|
||||
data-testid={`handleDeleteValue-${metadataField}`}
|
||||
onClick={this.handleDeleteValue}
|
||||
text={`Delete this label, and reassign all cells to type '${globals.unassignedCategoryLabel}'`}
|
||||
/>
|
||||
) : null}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
marginLeft: 0,
|
||||
position: "relative",
|
||||
top: -1,
|
||||
minHeight: 16
|
||||
}}
|
||||
data-testclass="seeActions"
|
||||
data-testid={`seeActions-${metadataField}`}
|
||||
icon="more"
|
||||
small
|
||||
minimal
|
||||
/>
|
||||
</Popover>
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
<svg
|
||||
display={isColorBy && categories ? "auto" : "none"}
|
||||
style={{
|
||||
marginLeft: 5,
|
||||
width: 11,
|
||||
height: 11,
|
||||
backgroundColor:
|
||||
isColorBy && categories
|
||||
? colorScale(categories.indexOf(value))
|
||||
: "inherit"
|
||||
}}
|
||||
/>
|
||||
{isUserAnno ? (
|
||||
<span
|
||||
onMouseEnter={this.handleMouseExit}
|
||||
onMouseLeave={this.handleMouseEnter}
|
||||
>
|
||||
<Popover
|
||||
interactionKind={PopoverInteractionKind.HOVER}
|
||||
boundary="window"
|
||||
position={Position.RIGHT_TOP}
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon="plus"
|
||||
data-testclass="handleAddCurrentSelectionToThisLabel"
|
||||
data-testid={`handleAddCurrentSelectionToThisLabel-${metadataField}`}
|
||||
onClick={this.handleAddCurrentSelectionToThisLabel}
|
||||
text={
|
||||
<span>
|
||||
Re-label currently selected cells as
|
||||
<span
|
||||
style={{
|
||||
fontStyle:
|
||||
displayString ===
|
||||
globals.unassignedCategoryLabel
|
||||
? "italic"
|
||||
: "auto"
|
||||
}}
|
||||
>
|
||||
{` ${displayString}`}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
disabled={this.isAddCurrentSelectionDisabled(
|
||||
metadataField,
|
||||
value
|
||||
)}
|
||||
/>
|
||||
{displayString !== globals.unassignedCategoryLabel ? (
|
||||
<MenuItem
|
||||
icon="edit"
|
||||
text="Edit this label's name"
|
||||
data-testclass="handleEditValue"
|
||||
data-testid={`handleEditValue-${metadataField}`}
|
||||
onClick={this.activateEditLabelMode}
|
||||
disabled={annotations.isEditingLabelName}
|
||||
/>
|
||||
) : null}
|
||||
{displayString !== globals.unassignedCategoryLabel ? (
|
||||
<MenuItem
|
||||
icon="delete"
|
||||
intent="danger"
|
||||
data-testclass="handleDeleteValue"
|
||||
data-testid={`handleDeleteValue-${metadataField}`}
|
||||
onClick={this.handleDeleteValue}
|
||||
text={`Delete this label, and reassign all cells to type '${globals.unassignedCategoryLabel}'`}
|
||||
/>
|
||||
) : null}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
marginLeft: 0,
|
||||
position: "relative",
|
||||
top: -1,
|
||||
minHeight: 16
|
||||
}}
|
||||
data-testclass="seeActions"
|
||||
data-testid={`seeActions-${metadataField}`}
|
||||
icon="more"
|
||||
small
|
||||
minimal
|
||||
/>
|
||||
</Popover>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user