1
0
mirror of synced 2024-11-25 14:56:03 +03:00

Adds aria-describedby, rule bailing

This commit is contained in:
Justin Schroeder 2020-05-15 14:08:58 -04:00
parent af5e23098d
commit fbc54bd72b
12 changed files with 299 additions and 50 deletions

File diff suppressed because one or more lines are too long

10
dist/formulate.min.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

6
dist/snow.min.css vendored

File diff suppressed because one or more lines are too long

View File

@ -80,7 +80,7 @@
<script> <script>
import context from './libs/context' import context from './libs/context'
import { shallowEqualObjects, parseRules, snakeToCamel, has, arrayify } from './libs/utils' import { shallowEqualObjects, parseRules, snakeToCamel, has, arrayify, groupBails } from './libs/utils'
export default { export default {
name: 'FormulateInput', name: 'FormulateInput',
@ -355,9 +355,13 @@ export default {
let rules = parseRules(this.validation, this.$formulate.rules(this.parsedValidationRules)) let rules = parseRules(this.validation, this.$formulate.rules(this.parsedValidationRules))
// Add in ruleRegistry rules. These are added directly via injection from // Add in ruleRegistry rules. These are added directly via injection from
// children and not part of the standard validation rule set. // children and not part of the standard validation rule set.
rules = this.ruleRegistry.length ? rules.concat(this.ruleRegistry) : rules rules = this.ruleRegistry.length ? this.ruleRegistry.concat(rules) : rules
this.pendingValidation = Promise.all( this.pendingValidation = this.runRules(rules)
rules.map(([rule, args, ruleName]) => { .then(messages => this.didValidate(messages))
return this.pendingValidation
},
runRules (rules) {
const run = ([rule, args, ruleName, modifier]) => {
var res = rule({ var res = rule({
value: this.context.model, value: this.context.model,
getFormValues: this.getFormValues.bind(this), getFormValues: this.getFormValues.bind(this),
@ -365,11 +369,28 @@ export default {
}, ...args) }, ...args)
res = (res instanceof Promise) ? res : Promise.resolve(res) res = (res instanceof Promise) ? res : Promise.resolve(res)
return res.then(result => result ? false : this.getMessage(ruleName, args)) return res.then(result => result ? false : this.getMessage(ruleName, args))
}
return new Promise(resolve => {
const resolveGroups = (groups, allMessages = []) => {
const ruleGroup = groups.shift()
if (Array.isArray(ruleGroup) && ruleGroup.length) {
Promise.all(ruleGroup.map(run))
.then(messages => messages.filter(m => !!m))
.then(messages => {
messages = Array.isArray(messages) ? messages : []
// The rule passed or its a non-bailing group, and there are additional groups to check, continue
if ((!messages.length || !ruleGroup.bail) && groups.length) {
return resolveGroups(groups, allMessages.concat(messages))
}
return resolve(allMessages.concat(messages))
})
} else {
resolve([])
}
}
resolveGroups(groupBails(rules))
}) })
)
.then(result => result.filter(result => result))
.then(messages => this.didValidate(messages))
return this.pendingValidation
}, },
didValidate (messages) { didValidate (messages) {
const validationChanged = !shallowEqualObjects(messages, this.validationErrors) const validationChanged = !shallowEqualObjects(messages, this.validationErrors)

View File

@ -119,6 +119,11 @@ function elementAttributes () {
attrs.name = this.name attrs.name = this.name
} }
// If there is help text, have this element be described by it.
if (this.help) {
attrs['aria-describedby'] = `${attrs.id}-help`
}
return attrs return attrs
} }

View File

@ -147,6 +147,9 @@ export default {
*/ */
matches: function ({ value }, ...stack) { matches: function ({ value }, ...stack) {
return Promise.resolve(!!stack.find(pattern => { return Promise.resolve(!!stack.find(pattern => {
if (typeof pattern === 'string' && pattern.substr(0, 1) === '/' && pattern.substr(-1) === '/') {
pattern = new RegExp(pattern.substr(1, pattern.length - 2))
}
if (pattern instanceof RegExp) { if (pattern instanceof RegExp) {
return pattern.test(value) return pattern.test(value)
} }
@ -278,5 +281,12 @@ export default {
*/ */
url: function ({ value }) { url: function ({ value }) {
return Promise.resolve(isUrl(value)) return Promise.resolve(isUrl(value))
},
/**
* Rule: not a true rule more like a compiler flag.
*/
bail: function () {
return Promise.resolve(true)
} }
} }

View File

@ -106,19 +106,19 @@ function parseRule (rule, rules) {
} }
if (Array.isArray(rule) && rule.length) { if (Array.isArray(rule) && rule.length) {
rule = rule.map(r => r) // light clone rule = rule.map(r => r) // light clone
const ruleName = snakeToCamel(rule.shift()) const [ruleName, modifier] = parseModifier(rule.shift())
if (typeof ruleName === 'string' && rules.hasOwnProperty(ruleName)) { if (typeof ruleName === 'string' && rules.hasOwnProperty(ruleName)) {
return [rules[ruleName], rule, ruleName] return [rules[ruleName], rule, ruleName, modifier]
} }
if (typeof ruleName === 'function') { if (typeof ruleName === 'function') {
return [ruleName, rule, ruleName] return [ruleName, rule, ruleName, modifier]
} }
} }
if (typeof rule === 'string') { if (typeof rule === 'string') {
const segments = rule.split(':') const segments = rule.split(':')
const ruleName = snakeToCamel(segments.shift()) const [ruleName, modifier] = parseModifier(segments.shift())
if (rules.hasOwnProperty(ruleName)) { if (rules.hasOwnProperty(ruleName)) {
return [rules[ruleName], segments.length ? segments.join(':').split(',') : [], ruleName] return [rules[ruleName], segments.length ? segments.join(':').split(',') : [], ruleName, modifier]
} else { } else {
throw new Error(`Unknown validation rule ${rule}`) throw new Error(`Unknown validation rule ${rule}`)
} }
@ -126,6 +126,68 @@ function parseRule (rule, rules) {
return false return false
} }
/**
* Return the rule name with the applicable modifier as an array.
* @param {string} ruleName
* @return {array} [ruleName, modifier]
*/
function parseModifier (ruleName) {
if (/^[\^]/.test(ruleName.charAt(0))) {
return [snakeToCamel(ruleName.substr(1)), ruleName.charAt(0)]
}
return [snakeToCamel(ruleName), null]
}
/**
* Given an array of rules, group them by bail signals. For example for this:
* bail|required|min:10|max:20
* we would expect:
* [[required], [min], [max]]
* because any sub-array failure would cause a shutdown. While
* ^required|min:10|max:10
* would return:
* [[required], [min, max]]
* and no bailing would produce:
* [[required, min, max]]
* @param {array} rules
*/
export function groupBails (rules) {
const groups = []
const bailIndex = rules.findIndex(([,, rule]) => rule.toLowerCase() === 'bail')
if (bailIndex >= 0) {
// Get all the rules until the first bail rule (dont include the bail)
const preBail = rules.splice(0, bailIndex + 1).slice(0, -1)
// Rules before the `bail` rule are non-bailing
preBail.length && groups.push(preBail)
// All remaining rules are bailing rule groups
rules.map(rule => groups.push(Object.defineProperty([rule], 'bail', { value: true })))
} else {
groups.push(rules)
}
return groups.reduce((groups, group) => {
const splitByMod = (group, bailGroup = false) => {
if (group.length < 2) {
return Object.defineProperty([group], 'bail', { value: bailGroup })
}
const splits = []
const modIndex = group.findIndex(([,,, modifier]) => modifier === '^')
if (modIndex >= 0) {
const preMod = group.splice(0, modIndex)
// rules before the modifier are non-bailing rules.
preMod.length && splits.push(...splitByMod(preMod, bailGroup))
splits.push(Object.defineProperty([group.shift()], 'bail', { value: true }))
// rules after the modifier are non-bailing rules.
group.length && splits.push(...splitByMod(group, bailGroup))
} else {
splits.push(group)
}
return splits
}
return groups.concat(splitByMod(group))
}, [])
}
/** /**
* Escape a string for use in regular expressions. * Escape a string for use in regular expressions.
* @param {string} string * @param {string} string

View File

@ -1,6 +1,7 @@
<template> <template>
<div <div
v-if="context.help" v-if="context.help"
:id="`${context.id}-help`"
:class="`formulate-input-help formulate-input-help--${context.helpPosition}`" :class="`formulate-input-help formulate-input-help--${context.helpPosition}`"
v-text="context.help" v-text="context.help"
/> />

View File

@ -220,5 +220,76 @@ describe('FormulateInput', () => {
await flushPromises() await flushPromises()
expect(wrapper.find('.my-errors').html()) expect(wrapper.find('.my-errors').html())
.toBe(`<ul class="my-errors">\n <li>Text is required.</li>\n</ul>`) .toBe(`<ul class="my-errors">\n <li>Text is required.</li>\n</ul>`)
// Clean up after this call — we should probably get rid of the singleton all together....
Formulate.extend({ slotComponents: { errors: 'FormulateErrors' }})
})
it('links help text with `aria-describedby`', async () => {
const wrapper = mount(FormulateInput, {
propsData: {
type: 'text',
validation: 'required',
errorBehavior: 'live',
value: 'bar',
help: 'Some help text'
}
})
await flushPromises()
const id = `${wrapper.vm.context.id}-help`
expect(wrapper.find('input').attributes('aria-describedby')).toBe(id)
expect(wrapper.find('.formulate-input-help').attributes().id).toBe(id)
});
it('it does not use aria-describedby if there is no help text', async () => {
const wrapper = mount(FormulateInput, {
propsData: {
type: 'text',
validation: 'required',
errorBehavior: 'live',
value: 'bar',
}
})
await flushPromises()
expect(wrapper.find('input').attributes('aria-describedby')).toBeFalsy()
});
it('can bail on validation when encountering the bail rule', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', validation: 'bail|required|in:xyz', errorBehavior: 'live' }
})
await flushPromises();
expect(wrapper.vm.context.visibleValidationErrors.length).toBe(1);
})
it('can show multiple validation errors if they occur before the bail rule', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', validation: 'required|in:xyz|bail', errorBehavior: 'live' }
})
await flushPromises();
expect(wrapper.vm.context.visibleValidationErrors.length).toBe(2);
})
it('can avoid bail behavior by using modifier', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', validation: '^required|in:xyz|min:10,length', errorBehavior: 'live', value: '123' }
})
await flushPromises();
expect(wrapper.vm.context.visibleValidationErrors.length).toBe(2);
})
it('prevents later error messages when modified rule fails', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', validation: '^required|in:xyz|min:10,length', errorBehavior: 'live' }
})
await flushPromises();
expect(wrapper.vm.context.visibleValidationErrors.length).toBe(1);
})
it('can bail in the middle of the rule set with a modifier', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', validation: 'required|^in:xyz|min:10,length', errorBehavior: 'live' }
})
await flushPromises();
expect(wrapper.vm.context.visibleValidationErrors.length).toBe(2);
}) })
}) })

View File

@ -306,6 +306,18 @@ describe('matches', () => {
it('passes on matching mixed regex and string', async () => { it('passes on matching mixed regex and string', async () => {
expect(await rules.matches({ value: 'first-fourth' }, 'second', /^third/, /fourth$/)).toBe(true) expect(await rules.matches({ value: 'first-fourth' }, 'second', /^third/, /fourth$/)).toBe(true)
}) })
it('fails on a regular expression encoded as a string', async () => {
expect(await rules.matches({ value: 'mypassword' }, '/[0-9]/')).toBe(false)
})
it('passes on a regular expression encoded as a string', async () => {
expect(await rules.matches({ value: 'mypa55word' }, '/[0-9]/')).toBe(true)
})
it('passes on a regular expression containing slashes', async () => {
expect(await rules.matches({ value: 'https://' }, '/https?:///')).toBe(true)
})
}) })
/** /**

View File

@ -1,37 +1,37 @@
import { parseRules, parseLocale, regexForFormat, cloneDeep, isValueType, snakeToCamel } from '@/libs/utils' import { parseRules, parseLocale, regexForFormat, cloneDeep, isValueType, snakeToCamel, groupBails } from '@/libs/utils'
import rules from '@/libs/rules' import rules from '@/libs/rules'
import FileUpload from '@/FileUpload'; import FileUpload from '@/FileUpload';
describe('parseRules', () => { describe('parseRules', () => {
it('parses single string rules, returning empty arguments array', () => { it('parses single string rules, returning empty arguments array', () => {
expect(parseRules('required', rules)).toEqual([ expect(parseRules('required', rules)).toEqual([
[rules.required, [], 'required'] [rules.required, [], 'required', null]
]) ])
}) })
it('throws errors for invalid validation rules', () => { it('throws errors for invalid validation rules', () => {
expect(() => { expect(() => {
parseRules('required|notarule', rules) parseRules('required|notarule', rules, null)
}).toThrow() }).toThrow()
}) })
it('parses arguments for a rule', () => { it('parses arguments for a rule', () => {
expect(parseRules('in:foo,bar', rules)).toEqual([ expect(parseRules('in:foo,bar', rules)).toEqual([
[rules.in, ['foo', 'bar'], 'in'] [rules.in, ['foo', 'bar'], 'in', null]
]) ])
}) })
it('parses multiple string rules and arguments', () => { it('parses multiple string rules and arguments', () => {
expect(parseRules('required|in:foo,bar', rules)).toEqual([ expect(parseRules('required|in:foo,bar', rules)).toEqual([
[rules.required, [], 'required'], [rules.required, [], 'required', null],
[rules.in, ['foo', 'bar'], 'in'] [rules.in, ['foo', 'bar'], 'in', null]
]) ])
}) })
it('parses multiple array rules and arguments', () => { it('parses multiple array rules and arguments', () => {
expect(parseRules(['required', 'in:foo,bar'], rules)).toEqual([ expect(parseRules(['required', 'in:foo,bar'], rules)).toEqual([
[rules.required, [], 'required'], [rules.required, [], 'required', null],
[rules.in, ['foo', 'bar'], 'in'] [rules.in, ['foo', 'bar'], 'in', null]
]) ])
}) })
@ -39,7 +39,21 @@ describe('parseRules', () => {
expect(parseRules([ expect(parseRules([
['matches', /^abc/, '1234'] ['matches', /^abc/, '1234']
], rules)).toEqual([ ], rules)).toEqual([
[rules.matches, [/^abc/, '1234'], 'matches'] [rules.matches, [/^abc/, '1234'], 'matches', null]
])
})
it('parses string rules with caret modifier', () => {
expect(parseRules('^required|min:10', rules)).toEqual([
[rules.required, [], 'required', '^'],
[rules.min, ['10'], 'min', null],
])
})
it('parses array rule with caret modifier', () => {
expect(parseRules([['required'], ['^max', '10']], rules)).toEqual([
[rules.required, [], 'required', null],
[rules.max, ['10'], 'max', '^'],
]) ])
}) })
}) })
@ -172,3 +186,72 @@ describe('parseLocale', () => {
expect(parseLocale('en')).toEqual(['en']) expect(parseLocale('en')).toEqual(['en'])
}) })
}) })
describe('groupBails', () => {
it('wraps non bailed rules in an array', () => {
const bailGroups = groupBails([[,,'required'], [,,'min']])
expect(bailGroups).toEqual(
[ [[,,'required'], [,,'min']] ] // dont bail on either of these
)
expect(bailGroups.map(group => !!group.bail)).toEqual([false])
})
it('splits bailed rules into two arrays array', () => {
const bailGroups = groupBails([[,,'required'], [,,'max'], [,, 'bail'], [,, 'matches'], [,,'min']])
expect(bailGroups).toEqual([
[ [,,'required'], [,,'max'] ], // dont bail on these
[ [,, 'matches'] ], // bail on this one
[ [,,'min'] ] // bail on this one
])
expect(bailGroups.map(group => !!group.bail)).toEqual([false, true, true])
})
it('splits entire rule set when bail is at the beginning', () => {
const bailGroups = groupBails([[,, 'bail'], [,,'required'], [,,'max'], [,, 'matches'], [,,'min']])
expect(bailGroups).toEqual([
[ [,, 'required'] ], // bail on this one
[ [,, 'max'] ], // bail on this one
[ [,, 'matches'] ], // bail on this one
[ [,, 'min'] ] // bail on this one
])
expect(bailGroups.map(group => !!group.bail)).toEqual([true, true, true, true])
})
it('splits no rules when bail is at the end', () => {
const bailGroups = groupBails([[,,'required'], [,,'max'], [,, 'matches'], [,,'min'], [,, 'bail']])
expect(bailGroups).toEqual([
[ [,, 'required'], [,, 'max'], [,, 'matches'], [,, 'min'] ] // dont bail on these
])
expect(bailGroups.map(group => !!group.bail)).toEqual([false])
})
it('splits individual modified names into two groups when at the begining', () => {
const bailGroups = groupBails([[,,'required', '^'], [,,'max'], [,, 'matches'], [,,'min'] ])
expect(bailGroups).toEqual([
[ [,, 'required', '^'] ], // bail on this one
[ [,, 'max'], [,, 'matches'], [,, 'min'] ] // dont bail on these
])
expect(bailGroups.map(group => !!group.bail)).toEqual([true, false])
})
it('splits individual modified names into three groups when in the middle', () => {
const bailGroups = groupBails([[,,'required'], [,,'max'], [,, 'matches', '^'], [,,'min'] ])
expect(bailGroups).toEqual([
[ [,, 'required'], [,, 'max'] ], // dont bail on these
[ [,, 'matches', '^'] ], // bail on this one
[ [,, 'min'] ] // dont bail on this
])
expect(bailGroups.map(group => !!group.bail)).toEqual([false, true, false])
})
it('splits individual modified names into four groups when used twice', () => {
const bailGroups = groupBails([[,,'required', '^'], [,,'max'], [,, 'matches', '^'], [,,'min'] ])
expect(bailGroups).toEqual([
[ [,, 'required', '^'] ], // bail on this
[ [,, 'max'] ], // dont bail on this
[ [,, 'matches', '^'] ], // bail on this
[ [,, 'min'] ] // dont bail on this
])
expect(bailGroups.map(group => !!group.bail)).toEqual([true, false, true, false])
})
})