-
Notifications
You must be signed in to change notification settings - Fork 903
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core/presentation): Begin adding support for error categories in…
… 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
1 parent
9954e0f
commit f2790a7
Showing
6 changed files
with
210 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
136 changes: 136 additions & 0 deletions
136
app/scripts/modules/core/src/presentation/forms/validation/categorizedErrors.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
}); | ||
}); |
51 changes: 51 additions & 0 deletions
51
app/scripts/modules/core/src/presentation/forms/validation/categorizedErrors.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters