mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-23 04:28:12 +08:00
category and label name validation updates (#1056)
* update label validation with new schema * add new error messages for new label errors; general cleanup
This commit is contained in:
@@ -67,42 +67,42 @@ class Categories extends React.Component {
|
||||
return false if this is a LEGAL/acceptable category name or NULL/empty string,
|
||||
or return an error type.
|
||||
*/
|
||||
if (!name) return false;
|
||||
|
||||
/* allow empty string */
|
||||
if (name === "") return false;
|
||||
|
||||
const { categoricalSelection } = this.props;
|
||||
const allCategoryNames = Object.keys(categoricalSelection);
|
||||
|
||||
/* check category name syntax */
|
||||
const error = AnnotationsHelpers.annotationNameIsErroneous(name);
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
/* disallow duplicates */
|
||||
if (allCategoryNames.indexOf(name) !== -1) {
|
||||
return "duplicate";
|
||||
}
|
||||
|
||||
if (!AnnotationsHelpers.isLegalAnnotationName(name)) {
|
||||
return "characters";
|
||||
}
|
||||
|
||||
/* otherwise, no error */
|
||||
return false;
|
||||
};
|
||||
|
||||
categoryNameErrorMessage = name => {
|
||||
const err = this.categoryNameError(name);
|
||||
if (err === false) return null;
|
||||
if (err === "duplicate") {
|
||||
return (
|
||||
<span>
|
||||
<span style={{ fontStyle: "italic" }}>{name}</span> already exists -
|
||||
no duplicates allowed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (err === "characters") {
|
||||
return (
|
||||
<span>
|
||||
<span style={{ fontStyle: "italic" }}>{name}</span> contains illegal
|
||||
characters. Hint: use alpha-numeric and underscore
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return err;
|
||||
|
||||
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, underscore and period allowed",
|
||||
"multi-space-run": "Multiple consecutive spaces not allowed"
|
||||
};
|
||||
const errorMessage = errorMessageMap[err] ?? "error";
|
||||
return <span>{errorMessage}</span>;
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@@ -158,25 +158,30 @@ class Category extends React.Component {
|
||||
return false if this is a LEGAL/acceptable category name or NULL/empty string,
|
||||
or return an error type.
|
||||
*/
|
||||
let error = false;
|
||||
if (name) {
|
||||
const { metadataField, universe } = this.props;
|
||||
const { obsByName } = universe.schema.annotations;
|
||||
|
||||
if (obsByName[metadataField].categories.indexOf(name) !== -1) {
|
||||
error = "duplicate";
|
||||
} else if (!AnnotationsHelpers.isLegalAnnotationName(name)) {
|
||||
error = "characters";
|
||||
}
|
||||
}
|
||||
return error;
|
||||
/* 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;
|
||||
};
|
||||
|
||||
labelNameErrorMessage = name => {
|
||||
const { metadataField } = this.props;
|
||||
const err = this.labelNameError(name);
|
||||
if (err === false) return null;
|
||||
|
||||
if (err === "duplicate") {
|
||||
/* duplicate error is special cased because it has special formatting */
|
||||
return (
|
||||
<span>
|
||||
<span style={{ fontStyle: "italic" }}>{name}</span> already exists
|
||||
@@ -185,15 +190,23 @@ class Category extends React.Component {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (err === "characters") {
|
||||
return (
|
||||
<span>
|
||||
<span style={{ fontStyle: "italic" }}>{name}</span> contains illegal
|
||||
characters. Hint: use alpha-numeric and underscore
|
||||
</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>;
|
||||
}
|
||||
return err;
|
||||
|
||||
/* no error, no message generated */
|
||||
return null;
|
||||
};
|
||||
|
||||
categoryNameErrorMessage = () => {
|
||||
@@ -201,76 +214,51 @@ class Category extends React.Component {
|
||||
const err = this.editedCategoryNameError();
|
||||
if (err === false) return null;
|
||||
|
||||
let markup = null;
|
||||
|
||||
if (err === "empty_string") {
|
||||
markup = (
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontStyle: "italic",
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{"Category name cannot be blank"}
|
||||
</span>
|
||||
);
|
||||
} else if (err === "already_exists") {
|
||||
markup = (
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontStyle: "italic",
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{"Category name must be unique"}
|
||||
</span>
|
||||
);
|
||||
} else if (err === "characters") {
|
||||
markup = (
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontStyle: "italic",
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{"Only alphanumeric and underscore allowed"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return markup;
|
||||
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, underscore and period allowed",
|
||||
"multi-space-run": "Multiple consecutive spaces not allowed"
|
||||
};
|
||||
const errorMessage = errorMessageMap[err] ?? "error";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontStyle: "italic",
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{errorMessage}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
editedCategoryNameError = () => {
|
||||
const { metadataField, categoricalSelection } = this.props;
|
||||
const { newCategoryText } = this.state;
|
||||
const allCategoryNames = _.keys(categoricalSelection);
|
||||
|
||||
const isEmptyString = newCategoryText === "";
|
||||
/* check for syntax errors in category name */
|
||||
const error = AnnotationsHelpers.annotationNameIsErroneous(newCategoryText);
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
/* check for duplicative categories */
|
||||
const allCategoryNames = _.keys(categoricalSelection);
|
||||
const categoryNameAlreadyExists =
|
||||
allCategoryNames.indexOf(newCategoryText) > -1;
|
||||
const sameName = newCategoryText === metadataField;
|
||||
|
||||
let error = false;
|
||||
|
||||
if (isEmptyString) {
|
||||
error = "empty_string";
|
||||
} else if (categoryNameAlreadyExists && !sameName) {
|
||||
error = "already_exists";
|
||||
} else if (!AnnotationsHelpers.isLegalAnnotationName(newCategoryText)) {
|
||||
error = "characters";
|
||||
if (categoryNameAlreadyExists && !sameName) {
|
||||
return "duplicate";
|
||||
}
|
||||
|
||||
return error;
|
||||
/* otherwise, no error */
|
||||
return false;
|
||||
};
|
||||
|
||||
toggleAll() {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
Popover,
|
||||
Position,
|
||||
PopoverInteractionKind,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Colors
|
||||
} from "@blueprintjs/core";
|
||||
import Occupancy from "./occupancy";
|
||||
import * as globals from "../../globals";
|
||||
@@ -85,75 +86,58 @@ class CategoryValue extends React.Component {
|
||||
valueNameErrorMessage = () => {
|
||||
const { editedLabelText } = this.state;
|
||||
const err = this.valueNameError();
|
||||
if (!err) return null;
|
||||
if (err === false) return null;
|
||||
|
||||
let markup = null;
|
||||
|
||||
if (err === "empty_string") {
|
||||
markup = (
|
||||
<span
|
||||
style={{
|
||||
fontStyle: "italic",
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{"Label cannot be blank"}
|
||||
</span>
|
||||
);
|
||||
} else if (err === "duplicate") {
|
||||
markup = (
|
||||
<span
|
||||
style={{
|
||||
fontStyle: "italic",
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{"Label must be unique"}
|
||||
</span>
|
||||
);
|
||||
} else if (err === "characters") {
|
||||
markup = (
|
||||
<span
|
||||
style={{
|
||||
fontStyle: "italic",
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{"Only alphanumeric and underscore allowed"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return markup;
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
valueNameError = () => {
|
||||
const { editedLabelText } = this.state;
|
||||
const { categoricalSelection, metadataField, categoryIndex } = this.props;
|
||||
|
||||
let err = null;
|
||||
/*
|
||||
check label syntax
|
||||
*/
|
||||
const err = AnnotationsHelpers.annotationNameIsErroneous(editedLabelText);
|
||||
if (err) return err;
|
||||
|
||||
/*
|
||||
disallow duplicates
|
||||
*/
|
||||
const category = categoricalSelection[metadataField];
|
||||
const displayString = String(
|
||||
category.categoryValues[categoryIndex]
|
||||
).valueOf();
|
||||
|
||||
if (editedLabelText === "") {
|
||||
err = "empty_string";
|
||||
} else if (
|
||||
if (
|
||||
category.categoryValues.indexOf(editedLabelText) > -1 &&
|
||||
editedLabelText !== displayString
|
||||
) {
|
||||
err = "duplicate";
|
||||
} else if (!AnnotationsHelpers.isLegalAnnotationName(editedLabelText)) {
|
||||
err = "characters";
|
||||
}
|
||||
return err;
|
||||
)
|
||||
return "duplicate";
|
||||
|
||||
/*
|
||||
otherwise, all good!
|
||||
*/
|
||||
return false;
|
||||
};
|
||||
|
||||
activateEditLabelMode = () => {
|
||||
@@ -221,6 +205,21 @@ 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({
|
||||
@@ -546,6 +545,7 @@ class CategoryValue extends React.Component {
|
||||
data-testclass="handleEditValue"
|
||||
data-testid={`handleEditValue-${metadataField}`}
|
||||
onClick={this.activateEditLabelMode}
|
||||
disabled={annotations.isEditingLabelName}
|
||||
/>
|
||||
) : null}
|
||||
{displayString !== globals.unassignedCategoryLabel ? (
|
||||
|
||||
@@ -183,7 +183,35 @@ export function createWritableAnnotationDimensions(world, crossfilter) {
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
const legalNames = /^\w+$/;
|
||||
export function isLegalAnnotationName(name) {
|
||||
return legalNames.test(name);
|
||||
const legalCharacters = /^(\w|[ .])+$/;
|
||||
export function annotationNameIsErroneous(name) {
|
||||
/*
|
||||
Validate the name - return:
|
||||
* false - a valid name
|
||||
* string - a named error, indicating why it was invalid.
|
||||
|
||||
Tests:
|
||||
0. must be string, non-null
|
||||
1. no leading or trailing spaces
|
||||
2. only accept alpha, numeric, underscore, period and space
|
||||
3. no runs of multiple spaces
|
||||
*/
|
||||
|
||||
if (name === "") {
|
||||
return "empty-string";
|
||||
}
|
||||
if (name[0] === " " || name[name.length - 1] === " ") {
|
||||
return "trim-spaces";
|
||||
}
|
||||
if (!legalCharacters.test(name)) {
|
||||
return "illegal-characters";
|
||||
}
|
||||
for (let i = 1, l = name.length; i < l; i += 1) {
|
||||
if (name[i] === " " && name[i - 1] === " ") {
|
||||
return "multi-space-run";
|
||||
}
|
||||
}
|
||||
|
||||
/* all is well! Indicte not erroneous with a false */
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user