Improve label picking (#1179)

* add simple error message helper

* port all label name pickers to use the new LabelInput component

* use pure components where possible

* cleanup

* more cleanup

* lint

* change new label prompt
This commit is contained in:
Bruce Martin
2020-02-28 15:46:40 -07:00
committed by GitHub
parent 1e4381ab7f
commit bf7d7342d5
27 changed files with 418 additions and 576 deletions

View File

@@ -12,7 +12,7 @@ import {
return promise to fetch the OBS annotations we need to load. Omit anything
we don't need.
*/
function obsAnnotationFetchAndLoad(dispatch, schema, universe) {
function obsAnnotationFetchAndLoad(dispatch, schema) {
const obsAnnotations = schema?.schema?.annotations?.obs ?? {};
const columns = obsAnnotations.columns ?? [];
const index = obsAnnotations.index ?? false;
@@ -42,7 +42,7 @@ function obsAnnotationFetchAndLoad(dispatch, schema, universe) {
/*
return promise fetching VAR annotations we need to load. Only index is currently used.
*/
function varAnnotationFetchAndLoad(dispatch, schema, universe) {
function varAnnotationFetchAndLoad(dispatch, schema) {
const varAnnotations = schema?.schema?.annotations?.var ?? {};
const index = varAnnotations.index ?? false;
const names = index ? [index] : [];
@@ -71,7 +71,7 @@ function varAnnotationFetchAndLoad(dispatch, schema, universe) {
/*
return promise fetching layout we need
*/
function layoutFetchAndLoad(dispatch, schema, universe) {
function layoutFetchAndLoad(dispatch) {
return Promise.all(
["layout/obs"]
.map(path => {

View File

@@ -74,7 +74,7 @@ class FilenameDialog extends React.Component {
color: Colors.ORANGE3
}}
>
{"Name cannot be blank"}
Name cannot be blank
</span>
);
} else if (err === "characters") {
@@ -87,7 +87,7 @@ class FilenameDialog extends React.Component {
color: Colors.ORANGE3
}}
>
{"Only alphanumeric and underscore allowed"}
Only alphanumeric and underscore allowed
</span>
);
}

View File

@@ -6,11 +6,9 @@ import { Button, Tooltip, Dialog, Classes, Colors } from "@blueprintjs/core";
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading
universe: state.universe
}))
class AnnoDialog extends React.Component {
class AnnoDialog extends React.PureComponent {
constructor(props) {
super(props);
this.state = {};
@@ -26,7 +24,6 @@ class AnnoDialog extends React.Component {
errorMessage,
validationError,
annoSelect,
ontologySelect,
annoInput,
handleCancel,
handleSubmit,
@@ -58,7 +55,6 @@ class AnnoDialog extends React.Component {
</p>
</div>
{annoSelect || null}
{ontologySelect || null}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
@@ -76,7 +72,7 @@ class AnnoDialog extends React.Component {
</Button>
) : null}
<Button
{...primaryButtonProps}
{...primaryButtonProps} // eslint-disable-line react/jsx-props-no-spreading
onClick={handleSubmit}
disabled={!text || validationError}
intent="primary"

View File

@@ -1,20 +1,17 @@
import React from "react";
import { connect } from "react-redux";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
import LabelInput from "./labelInput";
import { labelPrompt, 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
ontology: state.ontology
}))
class Category extends React.Component {
class Category extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
@@ -22,14 +19,15 @@ class Category extends React.Component {
};
}
disableAddNewLabelMode = () => {
disableAddNewLabelMode = e => {
const { dispatch } = this.props;
dispatch({
type: "annotation: disable add new label mode"
});
this.setState({
newLabelText: ""
});
dispatch({
type: "annotation: disable add new label mode"
});
if (e) e.preventDefault();
};
handleAddNewLabelToCategory = e => {
@@ -46,7 +44,7 @@ class Category extends React.Component {
e.preventDefault();
};
addLabelAndAssignCells = () => {
addLabelAndAssignCells = e => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
@@ -57,18 +55,7 @@ class Category extends React.Component {
newLabelText,
assignSelectedCells: true
});
};
handleCreateArbitraryLabel = newLabelTextNotInOntology => {
const { dispatch, metadataField } = this.props;
this.disableAddNewLabelMode();
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText: newLabelTextNotInOntology,
assignSelectedCells: false
});
e.preventDefault();
};
labelNameError = name => {
@@ -76,23 +63,18 @@ class Category extends React.Component {
return isLabelErroneous(name, metadataField, ontology, universe.schema);
};
labelNameErrorMessage = name => {
const { metadataField, ontology, universe } = this.props;
return labelErrorMessage(name, metadataField, ontology, universe.schema);
instruction = label => {
return labelPrompt(this.labelNameError(label), "New, unique label", ":");
};
/* leaky to have both of these in multiple components */
handleChoice = e => {
this.setState({ newLabelText: e.target });
};
handleTextChange = text => {
this.setState({ newLabelText: text });
handleChangeOrSelect = label => {
this.setState({ newLabelText: label });
};
render() {
const { newLabelText } = this.state;
const { metadataField, annotations, ontologyEnabled } = this.props;
const { metadataField, annotations, ontology } = this.props;
const ontologyEnabled = ontology?.enabled ?? false;
return (
<>
@@ -106,27 +88,26 @@ class Category extends React.Component {
"data-testid": `${metadataField}:submit-label`
}}
title="Add new label to category"
instruction="New, unique label name:"
instruction={this.instruction(newLabelText)}
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}
inputProps={{ "data-testid": `${metadataField}:new-label-name` }}
handleCreateArbitraryLabel={this.handleCreateArbitraryLabel}
handleItemChange={this.handleSuggestActiveItemChange}
handleChoice={this.handleChoice}
handleTextChange={this.handleTextChange}
isTextInvalid={this.labelNameError}
isTextInvalidErrorMessage={this.labelNameErrorMessage}
<LabelInput
labelSuggestions={ontologyEnabled ? ontology.terms : null}
onChange={this.handleChangeOrSelect}
onSelect={this.handleChangeOrSelect}
inputProps={{
"data-testid": `${metadataField}:new-label-name`,
leftIcon: "tag",
intent: "none",
autoFocus: true
}}
/>
}
/>

View File

@@ -1,131 +0,0 @@
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 = e => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
this.disableAddNewLabelFromOntologyMode();
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText,
assignSelectedCells: false
});
e.preventDefault();
};
addLabelAndAssignCells = () => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
this.disableAddNewLabelFromOntologyMode();
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText,
assignSelectedCells: true
});
};
handleCreateArbitraryLabel = newLabelTextNotInOntology => {
const { dispatch, metadataField } = this.props;
this.disableAddNewLabelFromOntologyMode();
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText: newLabelTextNotInOntology,
assignSelectedCells: false
});
};
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;

View File

@@ -1,9 +1,9 @@
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 LabelInput from "./labelInput";
import { labelPrompt } from "./labelUtil";
import { AnnotationsHelpers } from "../../util/stateManager";
@@ -11,11 +11,9 @@ import { AnnotationsHelpers } from "../../util/stateManager";
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
ontology: state.ontology
}))
class AnnoDialogEditCategoryName extends React.Component {
class AnnoDialogEditCategoryName extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
@@ -23,9 +21,9 @@ class AnnoDialogEditCategoryName extends React.Component {
};
}
handleCategoryEditTextChange = txt => {
handleChangeOrSelect = name => {
this.setState({
newCategoryText: txt
newCategoryText: name
});
};
@@ -61,50 +59,19 @@ class AnnoDialogEditCategoryName extends React.Component {
e.preventDefault();
};
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 = () => {
editedCategoryNameError = name => {
const { metadataField, categoricalSelection } = this.props;
const { newCategoryText } = this.state;
/* check for syntax errors in category name */
const error = AnnotationsHelpers.annotationNameIsErroneous(newCategoryText);
const error = AnnotationsHelpers.annotationNameIsErroneous(name);
if (error) {
return error;
}
/* check for duplicative categories */
const allCategoryNames = _.keys(categoricalSelection);
const categoryNameAlreadyExists =
allCategoryNames.indexOf(newCategoryText) > -1;
const sameName = newCategoryText === metadataField;
const categoryNameAlreadyExists = allCategoryNames.indexOf(name) > -1;
const sameName = name === metadataField;
if (categoryNameAlreadyExists && !sameName) {
return "duplicate";
}
@@ -113,9 +80,18 @@ class AnnoDialogEditCategoryName extends React.Component {
return false;
};
instruction = name => {
return labelPrompt(
this.editedCategoryNameError(name),
"New, unique category name",
":"
);
};
render() {
const { newCategoryText } = this.state;
const { metadataField, annotations } = this.props;
const { metadataField, annotations, ontology } = this.props;
const ontologyEnabled = ontology?.enabled ?? false;
return (
<>
@@ -131,22 +107,26 @@ class AnnoDialogEditCategoryName extends React.Component {
"data-testid": `${metadataField}:submit-category-edit`
}}
title="Edit category name"
instruction="New, unique category name:"
instruction={this.instruction(newCategoryText)}
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
<LabelInput
label={newCategoryText}
labelSuggestions={ontologyEnabled ? ontology.terms : null}
onChange={this.handleChangeOrSelect}
onSelect={this.handleChangeOrSelect}
inputProps={{
"data-testid": `${metadataField}:edit-category-name-text`
"data-testid": `${metadataField}:edit-category-name-text`,
leftIcon: "tag",
intent: "none",
autoFocus: true
}}
useSuggest={false}
text={newCategoryText}
handleTextChange={this.handleCategoryEditTextChange}
newLabelMessage="New category"
/>
}
/>

View File

@@ -1,48 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import { InputGroup } from "@blueprintjs/core";
const VanillaInput = props => {
const { text, handleTextChange, inputProps } = props;
return (
<InputGroup
{...inputProps}
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 { handleTextChange, text, ...restProps } = this.props;
return (
<div>
<VanillaInput
{...restProps}
text={text}
handleTextChange={handleTextChange}
/>
</div>
);
}
}
export default AnnoInputs;

View File

@@ -10,10 +10,9 @@ import {
} from "@blueprintjs/core";
@connect(state => ({
annotations: state.annotations,
ontologyEnabled: state.ontology?.enabled
annotations: state.annotations
}))
class AnnoMenuCategory extends React.Component {
class AnnoMenuCategory extends React.PureComponent {
constructor(props) {
super(props);
this.state = {};
@@ -27,14 +26,6 @@ class AnnoMenuCategory extends React.Component {
});
};
activateAddNewOntologyLabelMode = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: activate add new ontology label mode",
data: metadataField
});
};
activateEditCategoryMode = () => {
const { dispatch, metadataField } = this.props;
@@ -57,9 +48,7 @@ class AnnoMenuCategory extends React.Component {
metadataField,
annotations,
isUserAnno,
ontologyEnabled,
createText,
createFromOntologyText,
editText,
deleteText
} = this.props;
@@ -80,15 +69,6 @@ class AnnoMenuCategory extends React.Component {
onClick={this.activateAddNewLabelMode}
text={createText}
/>
{ontologyEnabled ? (
<MenuItem
icon="book"
data-testclass="activateAddNewOntologyLabelMode"
data-testid={`${metadataField}:add-new-ontology-label-mode`}
onClick={this.activateAddNewOntologyLabelMode}
text={createFromOntologyText}
/>
) : null}
<MenuItem
icon="edit"
disabled={annotations.isEditingCategoryName}

View File

@@ -7,11 +7,9 @@ import { Select } from "@blueprintjs/select";
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading
universe: state.universe
}))
class DuplicateCategorySelect extends React.Component {
class DuplicateCategorySelect extends React.PureComponent {
constructor(props) {
super(props);
this.state = {};

View File

@@ -6,13 +6,15 @@ import * as globals from "../../globals";
import Category from "./category";
import { AnnotationsHelpers, ControlsHelpers } from "../../util/stateManager";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import AnnoSelect from "./annoSelect";
import LabelInput from "./labelInput";
import { labelPrompt } from "./labelUtil";
@connect(state => ({
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false,
schema: state.world?.schema,
config: state.config
config: state.config,
ontology: state.ontology
}))
class Categories extends React.Component {
constructor(props) {
@@ -87,46 +89,30 @@ class Categories extends React.Component {
return false;
};
categoryNameErrorMessage = name => {
const err = this.categoryNameError(name);
if (err === false) return null;
const errorMessageMap = {
/* map error code to human readable error message */
"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>;
handleChange = name => {
this.setState({ newCategoryText: name });
};
handleNewCategoryText = txt => {
this.setState({ newCategoryText: txt });
handleSelect = name => {
this.setState({ newCategoryText: name });
};
handleChoice = e => {
/* Blueprint Suggest format */
this.setState({ newCategoryText: e.target });
instruction = name => {
return labelPrompt(
this.categoryNameError(name),
"New, unique category name",
":"
);
};
handleSuggestActiveItemChange = () => {};
render() {
const {
createAnnoModeActive,
categoryToDuplicate,
newCategoryText
} = this.state;
const {
categoricalSelection,
writableCategoriesEnabled,
schema,
config
} = this.props;
const { writableCategoriesEnabled, schema, config, ontology } = this.props;
const ontologyEnabled = ontology?.enabled ?? false;
/* all names, sorted in display order. Will be rendered in this order */
const allCategoryNames = ControlsHelpers.selectableCategoryNames(
@@ -143,22 +129,26 @@ class Categories extends React.Component {
<AnnoDialog
isActive={createAnnoModeActive}
title="Create new category"
instruction="New, unique category name:"
instruction={this.instruction(newCategoryText)}
cancelTooltipContent="Close this dialog without creating a category."
primaryButtonText="Create new category"
primaryButtonProps={{ "data-testid": "submit-category" }}
text={newCategoryText}
validationError={this.categoryNameError(newCategoryText)}
errorMessage={this.categoryNameErrorMessage(newCategoryText)}
handleSubmit={this.handleCreateUserAnno}
handleCancel={this.handleDisableAnnoMode}
annoInput={
<AnnoInputs
text={newCategoryText}
inputProps={{ "data-testid": "new-category-name" }}
handleItemChange={this.handleSuggestActiveItemChange}
handleChoice={this.handleChoice}
handleTextChange={this.handleNewCategoryText}
<LabelInput
labelSuggestions={ontologyEnabled ? ontology.terms : null}
onChange={this.handleChange}
onSelect={this.handleSelect}
inputProps={{
"data-testid": "new-category-name",
leftIcon: "tag",
intent: "none",
autoFocus: true
}}
newLabelMessage="New category"
/>
}
annoSelect={

View File

@@ -2,12 +2,11 @@ import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
import { Button, Tooltip, Icon, Spinner } from "@blueprintjs/core";
import { Button, Tooltip, Icon } from "@blueprintjs/core";
import CategoryFlipperLayout from "./categoryFlipperLayout";
import AnnoMenu from "./annoMenuCategory";
import AnnoDialogEditCategoryName from "./annoDialogEditCategoryName";
import AnnoDialogAddLabel from "./annoDialogAddLabel";
import AnnoDialogAddLabelFromOntology from "./annoDialogAddLabelFromOntology";
import * as globals from "../../globals";
@@ -15,10 +14,7 @@ import * as globals from "../../globals";
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
universe: state.universe
}))
class Category extends React.Component {
constructor(props) {
@@ -99,10 +95,11 @@ class Category extends React.Component {
}
}
renderIsStillLoading(metadataField) {
renderIsStillLoading() {
/*
We are still loading this category, so render a "busy" signal.
*/
const { metadataField } = this.props;
return (
<div
style={{
@@ -156,7 +153,7 @@ class Category extends React.Component {
const isStillLoading = !(categoricalSelection?.[metadataField] ?? false);
if (isStillLoading) {
return this.renderIsStillLoading(metadataField);
return this.renderIsStillLoading();
}
return (
@@ -220,13 +217,11 @@ class Category extends React.Component {
</div>
{<AnnoDialogEditCategoryName metadataField={metadataField} />}
{<AnnoDialogAddLabel metadataField={metadataField} />}
{<AnnoDialogAddLabelFromOntology metadataField={metadataField} />}
<div>
<AnnoMenu
metadataField={metadataField}
isUserAnno={isUserAnno}
createText="Add a new label to this category"
createFromOntologyText="Add a new label to this category using existing ontology terms"
editText="Edit this category's name"
deleteText="Delete this category, all associated labels, and remove all cell assignments"
/>

View File

@@ -0,0 +1,176 @@
import React from "react";
import { InputGroup, MenuItem, Keys } from "@blueprintjs/core";
import { Suggest } from "@blueprintjs/select";
import fuzzysort from "fuzzysort";
export default class LabelInput extends React.PureComponent {
/*
Input widget for text labels, which acts like an InputGroup, but will also
accept a suggestion list (of labels), with sublime-like suggest search.
Properties:
* labelSuggestions -- array of suggested lables. May be array of string or
other objects. If array of objects, specify `labelKey`. If null, suggestion
mode is disabled.
* onSelect -- optional, callback upon selection of item from labelSuggestions.
(label) => void
* onChange -- optional, callback upon change in text input. (label) => void
* label -- component value, for controlled use
* newLabelMessage -- text to display when user enters a label not in labelSuggestions
(not used if suggestion mode disabled)
* inputProps -- will be passed to InputGroup
* popoverProps -- will be passed to <Suggest>
*/
constructor(props) {
super(props);
const { label } = props;
const query = label || "";
const queryResults = this.filterLabels(query);
this.state = {
query,
queryResults
};
}
handleQueryChange = (query, event) => {
// https://github.com/palantir/blueprint/issues/2983
if (!event) return;
const queryResults = this.filterLabels(query);
this.setState({
query,
queryResults
});
const { onChange } = this.props;
if (onChange) onChange(query, event);
};
handleItemSelect = (item, event) => {
/* only report the select if not already reported via onChange() */
const { target } = item;
const { query } = this.state;
const { onSelect } = this.props;
if (target !== query && onSelect) onSelect(target, event);
};
handleKeyDown = e => {
/*
prevent these events from propagating to containing form/dialog
and causing further side effects (eg, closing dialog, submitting
form, etc).
*/
const { keyCode } = e;
if (keyCode === Keys.ENTER || keyCode === Keys.ESCAPE) {
e.preventDefault();
}
if (keyCode === Keys.ESCAPE) {
e.stopPropagation();
}
};
handleChange = e => {
const { onChange } = this.props;
if (onChange) onChange(e.target.value);
};
renderLabelSuggestion = (queryResult, { handleClick, modifiers }) => {
if (queryResult.newLabel) {
const { newLabelMessage } = this.props;
return (
<MenuItem
icon="flag"
active={modifiers.active}
disabled={modifiers.disabled}
key={queryResult.target}
onClick={handleClick}
text={<em>{queryResult.target}</em>}
label={newLabelMessage || "New label"}
/>
);
}
return (
<MenuItem
active={modifiers.active}
disabled={modifiers.disabled}
key={queryResult.target}
onClick={handleClick}
text={queryResult.target}
/>
);
};
/* maxinum number of suggestions */
static QueryResultLimit = 100;
filterLabels(query) {
const { labelSuggestions } = this.props;
if (!labelSuggestions) return [];
/* empty query is wildcard */
if (query === "") {
return labelSuggestions.slice(0, LabelInput.QueryResultLimit).map(l => ({
target: l,
score: -10000
}));
}
/* else, do a fuzzy query */
const options = {
limit: LabelInput.QueryResultLimit,
threshold: -10000 // don't return bad results
};
let queryResults = fuzzysort.go(query, labelSuggestions, options);
/* exact match will always be first in list */
if (query !== "" && queryResults[0]?.target !== query)
queryResults = [{ target: query, newLabel: true }, ...queryResults];
return queryResults;
}
render() {
const { props } = this;
const { labelSuggestions, label, autoFocus = true } = props;
const suggestEnabled = !!labelSuggestions && labelSuggestions.length > 0;
if (!suggestEnabled) {
return (
<InputGroup
autoFocus={autoFocus}
{...props.inputProps} // eslint-disable-line react/jsx-props-no-spreading
value={label}
onChange={this.handleChange}
/>
);
}
const popoverProps = {
minimal: true,
...props.popoverProps
};
const inputProps = {
...props.inputProps,
autoFocus: false
};
const { queryResults } = this.state;
return (
<>
<Suggest
fill
inputValueRenderer={i => i.target}
items={queryResults}
itemRenderer={this.renderLabelSuggestion}
onItemSelect={this.handleItemSelect}
query={label}
onQueryChange={this.handleQueryChange}
popoverProps={popoverProps}
inputProps={inputProps}
onKeyDown={this.handleKeyDown}
/>
</>
);
}
}

View File

@@ -1,4 +1,6 @@
import React from "react";
import { Colors } from "@blueprintjs/core";
import { AnnotationsHelpers } from "../../util/stateManager";
export function isLabelErroneous(label, metadataField, ontology, schema) {
@@ -24,34 +26,38 @@ export function isLabelErroneous(label, metadataField, ontology, schema) {
return false;
}
export function labelErrorMessage(label, metadataField, ontology, schema) {
const err = isLabelErroneous(label, metadataField, ontology, schema);
/* 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"
};
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>{" "}
export function labelPrompt(err, prolog, epilog) {
let errPrompt = null;
if (err) {
let errMsg = errorMessageMap[err] ?? "error";
errMsg = errMsg[0].toLowerCase() + errMsg.slice(1);
errPrompt = (
<span
style={{
marginTop: 7,
color: Colors.ORANGE3
}}
>
{errMsg}
</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;
return (
<span>
{prolog}
{err ? " - " : null}
{errPrompt}
{epilog}
</span>
);
}

View File

@@ -1,57 +0,0 @@
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;

View File

@@ -1,4 +1,3 @@
// jshint esversion: 6
import { connect } from "react-redux";
import React from "react";
@@ -9,17 +8,16 @@ import {
Popover,
Position,
PopoverInteractionKind,
Tooltip,
Colors
Tooltip
} from "@blueprintjs/core";
import Occupancy from "./occupancy";
import * as globals from "../../globals";
import styles from "./categorical.css";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import LabelInput from "./labelInput";
import { AnnotationsHelpers } from "../../util/stateManager";
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
import { labelPrompt, isLabelErroneous } from "./labelUtil";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
@@ -30,19 +28,13 @@ import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
schema: state.world?.schema,
world: state.world,
crossfilter: state.crossfilter,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
ontology: state.ontology
}))
class CategoryValue extends React.Component {
constructor(props) {
super(props);
this.state = {
editedLabelText: String(
props.categoricalSelection[props.metadataField].categoryValues[
props.categoryIndex
]
).valueOf()
editedLabelText: this.currentLabel()
};
}
@@ -54,9 +46,7 @@ class CategoryValue extends React.Component {
prevProps.categoryIndex !== categoryIndex
) {
this.setState({
editedLabelText: String(
categoricalSelection[metadataField].categoryValues[categoryIndex]
).valueOf()
editedLabelText: this.currentLabel()
});
}
}
@@ -98,49 +88,27 @@ class CategoryValue extends React.Component {
e.preventDefault();
};
handleCreateArbitraryLabel = editedLabelTextNotInOntology => {
handleCreateArbitraryLabel = txt => {
const { dispatch, metadataField, categoryIndex } = this.props;
const label = this.getLabel();
this.cancelEditMode();
dispatch({
type: "annotation: label edited",
metadataField,
editedLabel: editedLabelTextNotInOntology,
editedLabel: txt,
categoryIndex,
label
});
};
labelNameError = name => {
const {
metadataField,
ontology,
schema,
categoricalSelection,
categoryIndex
} = this.props;
const category = categoricalSelection[metadataField];
const displayString = String(
category.categoryValues[categoryIndex]
).valueOf();
if (name === displayString) return false;
const { metadataField, ontology, schema } = this.props;
if (name === this.currentLabel()) return false;
return isLabelErroneous(name, metadataField, ontology, schema);
};
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);
instruction = label => {
return labelPrompt(this.labelNameError(label), "New, unique label", ":");
};
activateEditLabelMode = () => {
@@ -154,6 +122,9 @@ class CategoryValue extends React.Component {
cancelEditMode = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
this.setState({
editedLabelText: this.currentLabel()
});
dispatch({
type: "annotation: cancel edit label mode",
metadataField,
@@ -255,6 +226,13 @@ class CategoryValue extends React.Component {
return label;
};
currentLabel() {
const { categoricalSelection, metadataField, categoryIndex } = this.props;
return String(
categoricalSelection[metadataField].categoryValues[categoryIndex]
).valueOf();
}
isAddCurrentSelectionDisabled(category, value) {
/*
disable "add current selection to label", if one of the following is true:
@@ -294,12 +272,13 @@ class CategoryValue extends React.Component {
schema,
isUserAnno,
annotations,
ontologyEnabled,
ontology,
// 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,
pointDilation
} = this.props;
const ontologyEnabled = ontology?.enabled ?? false;
const { editedLabelText } = this.state;
@@ -309,9 +288,7 @@ class CategoryValue extends React.Component {
const selected = category.categoryValueSelected[categoryIndex];
const count = category.categoryValueCounts[categoryIndex];
const value = category.categoryValues[categoryIndex];
const displayString = String(
category.categoryValues[categoryIndex]
).valueOf();
const displayString = this.currentLabel();
/* this is the color scale, so add swatches below */
const isColorBy = metadataField === colorAccessor;
@@ -442,30 +419,26 @@ class CategoryValue extends React.Component {
"data-testid": `${metadataField}:${displayString}:submit-label-edit`
}}
title="Edit label"
instruction={`New label text must be unique within category ${metadataField}:`}
instruction={this.instruction(editedLabelText)}
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.cancelEditMode}
annoInput={
<AnnoInputs
useSuggest={ontologyEnabled}
text={editedLabelText}
<LabelInput
label={editedLabelText}
labelSuggestions={ontologyEnabled ? ontology.terms : null}
onChange={this.handleTextChange}
onSelect={this.handleTextChange}
inputProps={{
"data-testid": `${metadataField}:${displayString}:edit-label-name`
"data-testid": `${metadataField}:${displayString}:edit-label-name`,
leftIcon: "tag",
intent: "none",
autoFocus: true
}}
handleCreateArbitraryLabel={
this.handleCreateArbitraryLabel
}
handleItemChange={this.handleSuggestActiveItemChange}
handleChoice={this.handleChoice}
handleTextChange={this.handleTextChange}
isTextInvalid={this.labelNameError}
isTextInvalidErrorMessage={this.labelNameErrorMessage}
/>
}
annoSelect={null}
@@ -475,7 +448,10 @@ class CategoryValue extends React.Component {
</div>
<span style={{ flexShrink: 0 }}>
{colorAccessor && !isColorBy && !annotations.isEditingLabelName ? (
<Occupancy category={category} {...this.props} />
<Occupancy
category={category}
{...this.props} // eslint-disable-line react/jsx-props-no-spreading
/>
) : null}
</span>
</div>

View File

@@ -2,11 +2,10 @@
/* rc slider https://www.npmjs.com/package/rc-slider */
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import * as globals from "../../globals";
import { Button } from "@blueprintjs/core";
import * as globals from "../../globals";
import HistogramBrush from "../brushableHistogram";
@connect(state => ({
@@ -28,7 +27,7 @@ class Continuous extends React.PureComponent {
};
};
renderIsStillLoading(zebra, key) {
static renderIsStillLoading(zebra, key) {
return (
<div
key={key}
@@ -45,7 +44,7 @@ class Continuous extends React.PureComponent {
alignItems: "center"
}}
>
<div style={{ minWidth: 30 }}></div>
<div style={{ minWidth: 30 }} />
<div style={{ display: "flex", alignSelf: "center" }}>
<span style={{ fontStyle: "italic" }}>{key}</span>
</div>
@@ -68,7 +67,7 @@ class Continuous extends React.PureComponent {
const obsIndex = schema.annotations.obs.index;
const allContinuousNames = schema.annotations.obs.columns
.filter(col => col.type === "int32" || col.type === "float32")
.filter(col => col.name != obsIndex)
.filter(col => col.name !== obsIndex)
.map(col => col.name);
/* initial value for iterator to simulate index, ranges is an object */
@@ -80,29 +79,31 @@ class Continuous extends React.PureComponent {
if (!obsAnnotations.hasCol(key)) {
// still loading!
zebra += 1;
return this.renderIsStillLoading(zebra, key);
} else {
// data loaded and available
const summary = obsAnnotations.col(key).summarize();
const nonFiniteExtent =
summary.min === undefined ||
summary.max === undefined ||
Number.isNaN(summary.min) ||
Number.isNaN(summary.max);
if (!summary.categorical && !nonFiniteExtent) {
zebra += 1;
return (
<HistogramBrush
key={key}
field={key}
isObs
zebra={zebra % 2 === 0}
ranges={summary}
handleColorAction={this.handleColorAction(key)}
/>
);
}
return Continuous.renderIsStillLoading(zebra, key);
}
// data loaded and available
const summary = obsAnnotations.col(key).summarize();
const nonFiniteExtent =
summary.min === undefined ||
summary.max === undefined ||
Number.isNaN(summary.min) ||
Number.isNaN(summary.max);
if (!summary.categorical && !nonFiniteExtent) {
zebra += 1;
return (
<HistogramBrush
key={key}
field={key}
isObs
zebra={zebra % 2 === 0}
ranges={summary}
handleColorAction={this.handleColorAction(key)}
/>
);
}
return null;
})}
</div>
);

View File

@@ -398,7 +398,7 @@ class Graph extends React.Component {
// don't return "change" of state unless we are really changing it!
const { toolSVG } = this.state;
if (toolSVG === undefined) return {};
else return { toolSVG: undefined };
return { toolSVG: undefined };
}
let handleStart;

View File

@@ -283,8 +283,14 @@ class MenuBar extends React.Component {
return (
<ButtonGroup style={{ marginRight: 10 }}>
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
<CellSetButton
{...this.props} // eslint-disable-line react/jsx-props-no-spreading
eitherCellSetOneOrTwo={1}
/>
<CellSetButton
{...this.props} // eslint-disable-line react/jsx-props-no-spreading
eitherCellSetOneOrTwo={2}
/>
{!differential.diffExp ? (
<Tooltip
content={tipMessage}

View File

@@ -55,46 +55,40 @@ const Annotations = (
}
/* CATEGORY */
case "annotation: activate add new label mode":
case "annotation: activate add new label mode": {
return {
...state,
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":
case "annotation: disable add new label mode": {
return {
...state,
isAddingNewLabel: false,
categoryAddingNewLabel: null
};
case "annotation: activate category edit mode":
}
case "annotation: activate category edit mode": {
return {
...state,
isEditingCategoryName: true,
categoryBeingEdited: action.data
};
case "annotation: disable category edit mode":
}
case "annotation: disable category edit mode": {
return {
...state,
isEditingCategoryName: false,
categoryBeingEdited: null
};
}
/* LABEL */
case "annotation: activate edit label mode":
case "annotation: activate edit label mode": {
return {
...state,
isEditingLabelName: true,
@@ -103,12 +97,16 @@ const Annotations = (
label: action.categoryIndex
}
};
case "annotation: cancel edit label mode":
}
case "annotation: cancel edit label mode": {
return {
...state,
isEditingLabelName: false,
labelEditable: { category: null, label: null }
};
}
default:
return state;
}

View File

@@ -1,5 +1,4 @@
import { ControlsHelpers as CH } from "../util/stateManager";
import * as globals from "../globals";
const CategoricalSelection = (
state,

View File

@@ -24,9 +24,8 @@ const CrossfilterReducerBase = (
) => {
switch (action.type) {
case "universe: column load success": {
const { schema, world, layoutChoice } = nextSharedState;
const { world, layoutChoice } = nextSharedState;
const { obsAnnotations, obsLayout } = world;
const { dim, dataframe } = action;
// ignore var dimension loads as these are not currently selectable
if (action.dim === "varAnnotations") return state;

View File

@@ -1,4 +1,3 @@
// jshint esversion: 6
const Ontology = (
state = {
enabled: false, // are ontology terms enabled?
@@ -9,10 +8,12 @@ const Ontology = (
action
) => {
switch (action.type) {
case "configuration load complete":
case "configuration load complete": {
/* eslint-disable camelcase */
const enabled =
action.config?.parameters?.annotations_cell_ontology_enabled ?? false;
const terms = action.config?.parameters?.annotations_cell_ontology_terms;
/* eslint-enable camelcase */
const termSet = new Set(terms);
return {
...state,
@@ -21,8 +22,10 @@ const Ontology = (
terms,
termSet
};
default:
}
default: {
return state;
}
}
};

View File

@@ -44,8 +44,6 @@ const skipOnActions = new Set([
/* annotation component action */
"annotation: activate add new label mode",
"annotation: activate add new ontology label mode",
"annotation: disable add new ontology label mode",
"annotation: disable add new label mode",
"annotation: activate category edit mode",
"annotation: disable category edit mode",

View File

@@ -10,7 +10,6 @@ TL;DR: sort order is:
import isNumber from "is-number";
import * as globals from "../globals";
import { memoize } from "./dataframe/util";
function caseInsensitiveCompare(a, b) {
const textA = String(a).toUpperCase();

View File

@@ -58,18 +58,18 @@ function topNCategories(colSchema, summary, N) {
);
const topNindices = new Set(sortIndex.slice(0, N));
const topNCategories = [];
const _topNCategories = [];
const topNCounts = [];
for (let i = 0; i < categories.length; i += 1) {
if (topNindices.has(i)) {
topNCategories.push(categories[i]);
_topNCategories.push(categories[i]);
topNCounts.push(counts[i]);
}
}
return [topNCategories, topNCounts];
return [_topNCategories, topNCounts];
}
export function selectableCategoryNames(schema, maxCategoryItems, names) {
export function selectableCategoryNames(schema, maxCatItems, names) {
/*
return all obs annotation names that are categorical AND have a
"reasonably" small number of categories AND are not the index column.

View File

@@ -1,11 +1,8 @@
import _ from "lodash";
import { unassignedCategoryLabel } from "../../globals";
import { decodeMatrixFBS } from "./matrix";
import * as Dataframe from "../dataframe";
import { isFpTypedArray } from "../typeHelpers";
import { indexEntireSchema } from "./schemaHelpers";
import { isCategoricalAnnotation } from "./annotationsHelpers";
import catLabelSort from "../catLabelSort";
/*
@@ -96,7 +93,7 @@ export function matrixFBSToDataframe(arrayBuffers) {
const fbs = arrayBuffers.map(ab => decodeMatrixFBS(ab, true)); // leave in place
/* check that all FBS have same row dimensionality */
const nRows = fbs[0].nRows;
const { nRows } = fbs[0];
fbs.forEach(b => {
if (b.nRows !== nRows)
throw new Error("FBS with inconsistent dimensionality");
@@ -181,7 +178,7 @@ export function addObsAnnotations(universe, df) {
// for all of the new data, reconcile with schema and sort categories.
const dfs = Array.isArray(df) ? df : [df];
const keys = dfs.map(df => df.colIndex.keys()).flat();
const keys = dfs.map(d => d.colIndex.keys()).flat();
const { schema } = universe;
keys.forEach(k => {
const colSchema = schema.annotations.obsByName[k];

View File

@@ -292,7 +292,7 @@ export function createObsDimensions(crossfilter, world, XYdimNames) {
for which we have a supported type, *except* for the index column, indicated
by schema.annotations.obs.index.
*/
const { schema, obsLayout, obsAnnotations } = world;
const { schema, obsLayout } = world;
const indexName = schema.annotations.obs.index;
const annoList = schema.annotations.obs.columns.filter(
anno => anno.name !== indexName