Adds aria-describedby, rule bailing
This commit is contained in:
parent
af5e23098d
commit
fbc54bd72b
6
dist/formulate.esm.js
vendored
6
dist/formulate.esm.js
vendored
File diff suppressed because one or more lines are too long
10
dist/formulate.min.js
vendored
10
dist/formulate.min.js
vendored
File diff suppressed because one or more lines are too long
6
dist/formulate.umd.js
vendored
6
dist/formulate.umd.js
vendored
File diff suppressed because one or more lines are too long
6
dist/snow.min.css
vendored
6
dist/snow.min.css
vendored
File diff suppressed because one or more lines are too long
@ -80,7 +80,7 @@
|
||||
|
||||
<script>
|
||||
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 {
|
||||
name: 'FormulateInput',
|
||||
@ -355,22 +355,43 @@ export default {
|
||||
let rules = parseRules(this.validation, this.$formulate.rules(this.parsedValidationRules))
|
||||
// Add in ruleRegistry rules. These are added directly via injection from
|
||||
// children and not part of the standard validation rule set.
|
||||
rules = this.ruleRegistry.length ? rules.concat(this.ruleRegistry) : rules
|
||||
this.pendingValidation = Promise.all(
|
||||
rules.map(([rule, args, ruleName]) => {
|
||||
var res = rule({
|
||||
value: this.context.model,
|
||||
getFormValues: this.getFormValues.bind(this),
|
||||
name: this.context.name
|
||||
}, ...args)
|
||||
res = (res instanceof Promise) ? res : Promise.resolve(res)
|
||||
return res.then(result => result ? false : this.getMessage(ruleName, args))
|
||||
})
|
||||
)
|
||||
.then(result => result.filter(result => result))
|
||||
rules = this.ruleRegistry.length ? this.ruleRegistry.concat(rules) : rules
|
||||
this.pendingValidation = this.runRules(rules)
|
||||
.then(messages => this.didValidate(messages))
|
||||
return this.pendingValidation
|
||||
},
|
||||
runRules (rules) {
|
||||
const run = ([rule, args, ruleName, modifier]) => {
|
||||
var res = rule({
|
||||
value: this.context.model,
|
||||
getFormValues: this.getFormValues.bind(this),
|
||||
name: this.context.name
|
||||
}, ...args)
|
||||
res = (res instanceof Promise) ? res : Promise.resolve(res)
|
||||
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))
|
||||
})
|
||||
},
|
||||
didValidate (messages) {
|
||||
const validationChanged = !shallowEqualObjects(messages, this.validationErrors)
|
||||
this.validationErrors = messages
|
||||
|
@ -119,6 +119,11 @@ function elementAttributes () {
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -147,6 +147,9 @@ export default {
|
||||
*/
|
||||
matches: function ({ value }, ...stack) {
|
||||
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) {
|
||||
return pattern.test(value)
|
||||
}
|
||||
@ -278,5 +281,12 @@ export default {
|
||||
*/
|
||||
url: function ({ value }) {
|
||||
return Promise.resolve(isUrl(value))
|
||||
},
|
||||
|
||||
/**
|
||||
* Rule: not a true rule — more like a compiler flag.
|
||||
*/
|
||||
bail: function () {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
}
|
||||
|
@ -106,19 +106,19 @@ function parseRule (rule, rules) {
|
||||
}
|
||||
if (Array.isArray(rule) && rule.length) {
|
||||
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)) {
|
||||
return [rules[ruleName], rule, ruleName]
|
||||
return [rules[ruleName], rule, ruleName, modifier]
|
||||
}
|
||||
if (typeof ruleName === 'function') {
|
||||
return [ruleName, rule, ruleName]
|
||||
return [ruleName, rule, ruleName, modifier]
|
||||
}
|
||||
}
|
||||
if (typeof rule === 'string') {
|
||||
const segments = rule.split(':')
|
||||
const ruleName = snakeToCamel(segments.shift())
|
||||
const [ruleName, modifier] = parseModifier(segments.shift())
|
||||
if (rules.hasOwnProperty(ruleName)) {
|
||||
return [rules[ruleName], segments.length ? segments.join(':').split(',') : [], ruleName]
|
||||
return [rules[ruleName], segments.length ? segments.join(':').split(',') : [], ruleName, modifier]
|
||||
} else {
|
||||
throw new Error(`Unknown validation rule ${rule}`)
|
||||
}
|
||||
@ -126,6 +126,68 @@ function parseRule (rule, rules) {
|
||||
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.
|
||||
* @param {string} string
|
||||
|
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="context.help"
|
||||
:id="`${context.id}-help`"
|
||||
:class="`formulate-input-help formulate-input-help--${context.helpPosition}`"
|
||||
v-text="context.help"
|
||||
/>
|
||||
|
@ -220,5 +220,76 @@ describe('FormulateInput', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.my-errors').html())
|
||||
.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);
|
||||
})
|
||||
})
|
||||
|
@ -306,6 +306,18 @@ describe('matches', () => {
|
||||
it('passes on matching mixed regex and string', async () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
@ -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 FileUpload from '@/FileUpload';
|
||||
|
||||
describe('parseRules', () => {
|
||||
it('parses single string rules, returning empty arguments array', () => {
|
||||
expect(parseRules('required', rules)).toEqual([
|
||||
[rules.required, [], 'required']
|
||||
[rules.required, [], 'required', null]
|
||||
])
|
||||
})
|
||||
|
||||
it('throws errors for invalid validation rules', () => {
|
||||
expect(() => {
|
||||
parseRules('required|notarule', rules)
|
||||
parseRules('required|notarule', rules, null)
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
it('parses arguments for a rule', () => {
|
||||
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', () => {
|
||||
expect(parseRules('required|in:foo,bar', rules)).toEqual([
|
||||
[rules.required, [], 'required'],
|
||||
[rules.in, ['foo', 'bar'], 'in']
|
||||
[rules.required, [], 'required', null],
|
||||
[rules.in, ['foo', 'bar'], 'in', null]
|
||||
])
|
||||
})
|
||||
|
||||
it('parses multiple array rules and arguments', () => {
|
||||
expect(parseRules(['required', 'in:foo,bar'], rules)).toEqual([
|
||||
[rules.required, [], 'required'],
|
||||
[rules.in, ['foo', 'bar'], 'in']
|
||||
[rules.required, [], 'required', null],
|
||||
[rules.in, ['foo', 'bar'], 'in', null]
|
||||
])
|
||||
})
|
||||
|
||||
@ -39,7 +39,21 @@ describe('parseRules', () => {
|
||||
expect(parseRules([
|
||||
['matches', /^abc/, '1234']
|
||||
], 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'])
|
||||
})
|
||||
})
|
||||
|
||||
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])
|
||||
})
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user