Skip to content

Commit

Permalink
feat(core/presentation): Begin adding support for error categories in…
Browse files Browse the repository at this point in the history
… validation API (#7467)

This PR introduces initial support for two features:

- The ability to embed validation categories _inside_ a validation message and extract them later.
Validation categories are pre-defined by the validation API.
A validator can return a validation message prefixed with the category.
The `categorizeErrorMessage` function categorizes an error message into a pre-defined category.

Example:
the message 'Error: This is an error' is processed into:
category: 'error'
message: 'This is an error'.

- The ability to categorize validation messages from a raw Errors object (Formik style) into category buckets.
Given an errors object, the `categorizeErrors` function returns an object with category keys.
Each error message from the raw errors object is first categorized and then stored inside the categorized errors object.

Example:
This raw errors object: { foo: 'Warning: a warning', bar: 'Error: an error' }
is transformed into a categorized errors object:
{
  warning: { foo: 'a warning' },
  error: { bar: 'an error' } },
}

The plan is to then use the categorized error object within the SpinFormik component.

This approach (to embed the validation message category directly into the validation message) was chosen because it doesn't violate the contract of formik validation  That is, we still return strings from validation functions.  This will allow this feature to degrade gracefully, in a scenario where the outer code doesn't understand the categorized validation paradigm.
  • Loading branch information
christopherthielen authored Oct 1, 2019
1 parent 9954e0f commit f2790a7
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
FormikForm,
FormikFormField,
IFieldLayoutProps,
IFieldValidationStatus,
IValidationCategory,
TextInput,
} from 'core/presentation';
import { ValidationMessage } from 'core/validation';
Expand Down Expand Up @@ -108,7 +108,7 @@ export class FormikExpressionRegexField extends React.Component<
}
}

private renderFormField(validationMessage: React.ReactNode, validationStatus: IFieldValidationStatus, regex: string) {
private renderFormField(validationMessage: React.ReactNode, validationStatus: IValidationCategory, regex: string) {
const { context, placeholder, help, label, actions } = this.props;

return (
Expand Down
9 changes: 4 additions & 5 deletions app/scripts/modules/core/src/presentation/forms/interface.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import * as React from 'react';
import { FieldProps } from 'formik';
import * as React from 'react';

import { IValidator } from './validation';
export type IFieldValidationStatus = 'success' | 'none' | 'error' | 'warning' | 'message';
import { IValidationCategory, IValidator } from './validation';

/** These props are used by FormField and FormikFormField components */
export interface IFieldLayoutPropsWithoutInput extends IValidationProps {
Expand Down Expand Up @@ -35,7 +34,7 @@ export type OmitControlledInputPropsFrom<T> = Omit<T, keyof IControlledInputProp
export interface IValidationProps {
touched?: boolean;
validationMessage?: React.ReactNode;
validationStatus?: IFieldValidationStatus;
validationStatus?: IValidationCategory;
addValidator?: (validator: IValidator) => void;
removeValidator?: (validator: IValidator) => void;
}
Expand All @@ -58,5 +57,5 @@ export interface IFormFieldApi {
value(): any;
touched(): boolean;
validationMessage(): React.ReactNode;
validationStatus(): IFieldValidationStatus;
validationStatus(): IValidationCategory;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { categorizeErrorMessage, categorizeErrors } from './categorizedErrors';

describe('categorizeErrorMessage', () => {
it('returns an array of length 2', () => {
const result = categorizeErrorMessage('');
expect(Array.isArray(result)).toBeTruthy();
expect(result.length).toBe(2);
});

it('returns the matching category key if the input starts with a category label and ": "', () => {
expect(categorizeErrorMessage('Error: ')[0]).toEqual('error');
expect(categorizeErrorMessage('Warning: ')[0]).toEqual('warning');
expect(categorizeErrorMessage('Async: ')[0]).toEqual('async');
expect(categorizeErrorMessage('Message: ')[0]).toEqual('message');
});

it('returns "error" category key by default if no category label is present', () => {
expect(categorizeErrorMessage('there was an error')[0]).toEqual('error');
});

it('returns the entire error message if no category label is present', () => {
expect(categorizeErrorMessage('there was an error')[1]).toEqual('there was an error');
});

it('returns the error message without the label prefix', () => {
expect(categorizeErrorMessage('Warning: something sorta bad')[1]).toEqual('something sorta bad');
});
});

describe('categorizedErrors', () => {
const emptyErrors = Object.freeze({
async: {},
error: {},
message: {},
success: {},
warning: {},
});

it('returns an object with all error categories as keys', () => {
const categories = categorizeErrors({});
expect(Object.keys(categories).sort()).toEqual(['async', 'error', 'message', 'success', 'warning']);
});

it('categorizes an unlabeled error message into "error"', () => {
const rawErrors = { foo: 'An error with foo' };
const categorized = categorizeErrors(rawErrors);

const error = { foo: 'An error with foo' };
expect(categorized).toEqual({ ...emptyErrors, error });
});

it('categorizes a labeled warning message into "warning"', () => {
const rawErrors = { foo: 'Warning: A warning about foo' };
const categorized = categorizeErrors(rawErrors);

const warning = { foo: 'A warning about foo' };
expect(categorized).toEqual({ ...emptyErrors, warning });
});

it('categorizes multiple labeled messages into their respective buckets', () => {
const rawErrors = {
foo: 'Warning: A warning about foo',
bar: 'Async: Loading some data',
baz: 'Message: The sky is blue',
};
const categorized = categorizeErrors(rawErrors);

const warning = { foo: 'A warning about foo' };
const async = { bar: 'Loading some data' };
const message = { baz: 'The sky is blue' };
expect(categorized).toEqual({ ...emptyErrors, warning, async, message });
});

it('categorizes multiple messages with the same label into the respective bucket', () => {
const rawErrors = {
foo: 'Message: Two plus two is four',
bar: 'Message: Fear leads to anger',
baz: 'Message: The sky is blue',
};
const categorized = categorizeErrors(rawErrors);

const message = {
foo: 'Two plus two is four',
bar: 'Fear leads to anger',
baz: 'The sky is blue',
};
expect(categorized).toEqual({ ...emptyErrors, message });
});

it('maps deeply nested error messages to the same path into each respective bucket', () => {
const rawErrors = {
people: [
{
name: {
first: 'Warning: First name is required',
last: 'Warning: Last name is required',
},
},
{
age: 'Error: Age cannot be negative',
},
],
deep: {
nested: {
object: 'Error: This is a deeply nested error message',
},
},
};
const categorized = categorizeErrors(rawErrors);

const warning = {
people: [
{
name: {
first: 'First name is required',
last: 'Last name is required',
},
},
],
};

const error = {
people: [
// empty array element because array indexes must match up with the source errors object
undefined,
{ age: 'Age cannot be negative' },
],
deep: {
nested: {
object: 'This is a deeply nested error message',
},
},
};
expect(categorized).toEqual({ ...emptyErrors, warning, error });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { set, values } from 'lodash';
import { traverseObject } from '../../../utils';

import { categoryLabels, ICategorizedErrors, IValidationCategory } from './validation';

// Category label strings, e.g., ['Error', 'Warning', ...]
const labels = values(categoryLabels);
const statusKeys = Object.keys(categoryLabels);
const inverseLabels: { [label: string]: IValidationCategory } = Object.keys(categoryLabels).reduce(
(acc, key: IValidationCategory) => ({ ...acc, [categoryLabels[key]]: key }),
{},
);

// A regular expression which captures the category label and validation message from a validation message
// I.e., for the string: "Error: There was a fatal error"
// this captures "Error" and "There was a fatal error"
const errorMessageRegexp = new RegExp(`^(${labels.join('|')}): (.*)$`);

// Takes an errorMessage with embedded category and extracts the category and message
// Example: "Error: there was an error" => ['error', 'there was an error']
// Example: "this message has no explicit category" => ['error', 'this message has no explicit category']
export const categorizeErrorMessage = (errorMessage: string): [IValidationCategory, string] => {
const result = errorMessageRegexp.exec(errorMessage);
if (!result) {
// If no known category label was found embedded in the error message, default the category to 'error'
return ['error', errorMessage];
}
const [label, message] = result.slice(1);
const status = inverseLabels[label];

return [status, message];
};

/** Organizes errors from an errors object into ICategorizedErrors buckets. */
export const categorizeErrors = (errors: any): ICategorizedErrors => {
// Build an empty Categorized Errors object
const categories: ICategorizedErrors = statusKeys.reduce((acc, status) => ({ ...acc, [status]: {} }), {}) as any;

// Given a path and a validation message, store the validation message into the same path of the correct category
const storeMessageInCategory = (path: string, errorMessage: any) => {
const [status, message] = categorizeErrorMessage(errorMessage);

if (message) {
set(categories[status], path, message);
}
};

traverseObject(errors, storeMessageInCategory, true);

return categories;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@
export type IValidatorResult = undefined | string;
export type IValidator = (value: any, label?: string) => IValidatorResult;

export const categoryLabels = {
async: 'Async',
error: 'Error',
message: 'Message',
success: 'Success',
warning: 'Warning',
};

type ICategoryLabels = typeof categoryLabels;
export type IValidationCategory = keyof typeof categoryLabels;
export type ICategorizedErrors = {
[P in keyof ICategoryLabels]: any;
};

export interface IFormValidator {
/**
* Defines a new form field to validate
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as React from 'react';
import { IValidationCategory } from '../presentation/forms/validation';

export interface IValidationMessageProps {
message: React.ReactNode;
type: 'success' | 'error' | 'warning' | 'message' | 'none';
type: IValidationCategory | undefined;
// default: true
showIcon?: boolean;
}
Expand All @@ -12,6 +13,7 @@ const iconClassName = {
error: 'fa fa-exclamation-circle',
warning: 'fa fa-exclamation-circle',
message: 'icon-view-1',
async: 'fa fa-spinner fa-spin',
none: '',
};

Expand Down

0 comments on commit f2790a7

Please sign in to comment.