feat: FormularioForm - added ::runValidation() method to run entire form validation manually and return all form violations
This commit is contained in:
parent
4e05844e73
commit
aee0dc977a
@ -56,7 +56,7 @@ export default class FormularioField extends Vue {
|
||||
@Inject({ default: undefined }) __FormularioForm_unregister!: Function|undefined
|
||||
|
||||
@Inject({ default: () => (): Record<string, unknown> => ({}) })
|
||||
__FormularioForm_getValue!: () => Record<string, unknown>
|
||||
__FormularioForm_getState!: () => Record<string, unknown>
|
||||
|
||||
@Model('input', { default: '' }) value!: unknown
|
||||
|
||||
@ -79,7 +79,7 @@ export default class FormularioField extends Vue {
|
||||
@Prop({ default: () => <U, T>(value: U|Empty): U|T|Empty => value }) modelGetConverter!: ModelGetConverter
|
||||
@Prop({ default: () => <T, U>(value: U|T): U|T => value }) modelSetConverter!: ModelSetConverter
|
||||
|
||||
public proxy: unknown = this.getInitialValue()
|
||||
public proxy: unknown = this.hasModel ? this.value : ''
|
||||
|
||||
private localErrors: string[] = []
|
||||
|
||||
@ -148,10 +148,17 @@ export default class FormularioField extends Vue {
|
||||
return messages
|
||||
}
|
||||
|
||||
@Watch('value')
|
||||
private onValueChange (newValue: unknown, oldValue: unknown): void {
|
||||
if (this.hasModel && !shallowEquals(newValue, oldValue)) {
|
||||
this.model = newValue
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('proxy')
|
||||
private onProxyChange (newValue: unknown, oldValue: unknown): void {
|
||||
if (!this.hasModel && !shallowEquals(newValue, oldValue)) {
|
||||
this.context.model = newValue
|
||||
this.model = newValue
|
||||
}
|
||||
if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
|
||||
this.runValidation()
|
||||
@ -160,54 +167,42 @@ export default class FormularioField extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('value')
|
||||
private onValueChange (newValue: unknown, oldValue: unknown): void {
|
||||
if (this.hasModel && !shallowEquals(newValue, oldValue)) {
|
||||
this.context.model = newValue
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public created (): void {
|
||||
if (!shallowEquals(this.model, this.proxy)) {
|
||||
this.model = this.proxy
|
||||
}
|
||||
}
|
||||
|
||||
created (): void {
|
||||
this.initProxy()
|
||||
if (typeof this.__FormularioForm_register === 'function') {
|
||||
this.__FormularioForm_register(this.fullPath, this)
|
||||
}
|
||||
|
||||
if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
|
||||
this.runValidation()
|
||||
}
|
||||
}
|
||||
|
||||
beforeDestroy (): void {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public beforeDestroy (): void {
|
||||
if (typeof this.__FormularioForm_unregister === 'function') {
|
||||
this.__FormularioForm_unregister(this.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
private getInitialValue (): unknown {
|
||||
return has(this.$options.propsData || {}, 'value') ? this.value : ''
|
||||
}
|
||||
|
||||
private initProxy (): void {
|
||||
// This should only be run immediately on created and ensures that the
|
||||
// proxy and the model are both the same before any additional registration.
|
||||
if (!shallowEquals(this.context.model, this.proxy)) {
|
||||
this.context.model = this.proxy
|
||||
}
|
||||
}
|
||||
|
||||
runValidation (): Promise<Violation[]> {
|
||||
public runValidation (): Promise<Violation[]> {
|
||||
this.validationRun = this.validate().then(violations => {
|
||||
const validationChanged = !shallowEquals(violations, this.violations)
|
||||
this.violations = violations
|
||||
|
||||
if (validationChanged) {
|
||||
const payload = {
|
||||
name: this.context.name,
|
||||
this.emitValidation({
|
||||
name: this.fullPath,
|
||||
violations: this.violations,
|
||||
}
|
||||
this.$emit('validation', payload)
|
||||
if (typeof this.__FormularioForm_emitValidation === 'function') {
|
||||
this.__FormularioForm_emitValidation(payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return this.violations
|
||||
@ -216,7 +211,7 @@ export default class FormularioField extends Vue {
|
||||
return this.validationRun
|
||||
}
|
||||
|
||||
validate (): Promise<Violation[]> {
|
||||
private validate (): Promise<Violation[]> {
|
||||
return validate(processConstraints(
|
||||
this.validation,
|
||||
this.$formulario.getRules(this.normalizedValidationRules),
|
||||
@ -224,11 +219,18 @@ export default class FormularioField extends Vue {
|
||||
), {
|
||||
value: this.context.model,
|
||||
name: this.context.name,
|
||||
formValues: this.__FormularioForm_getValue(),
|
||||
formValues: this.__FormularioForm_getState(),
|
||||
})
|
||||
}
|
||||
|
||||
hasValidationErrors (): Promise<boolean> {
|
||||
private emitValidation (payload: { name: string; violations: Violation[] }): void {
|
||||
this.$emit('validation', payload)
|
||||
if (typeof this.__FormularioForm_emitValidation === 'function') {
|
||||
this.__FormularioForm_emitValidation(payload)
|
||||
}
|
||||
}
|
||||
|
||||
public hasValidationErrors (): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
this.$nextTick(() => {
|
||||
this.validationRun.then(() => resolve(this.violations.length > 0))
|
||||
@ -239,7 +241,7 @@ export default class FormularioField extends Vue {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
setErrors (errors: string[]): void {
|
||||
public setErrors (errors: string[]): void {
|
||||
if (!this.errorsDisabled) {
|
||||
this.localErrors = arrayify(errors) as string[]
|
||||
}
|
||||
@ -248,7 +250,7 @@ export default class FormularioField extends Vue {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
resetValidation (): void {
|
||||
public resetValidation (): void {
|
||||
this.localErrors = []
|
||||
this.violations = []
|
||||
}
|
||||
|
@ -28,18 +28,22 @@ import FormularioField from '@/FormularioField.vue'
|
||||
|
||||
import { Violation } from '@/validation/validator'
|
||||
|
||||
type ErrorsRecord = Record<string, string[]>
|
||||
|
||||
type ValidationEventPayload = {
|
||||
name: string;
|
||||
violations: Violation[];
|
||||
}
|
||||
|
||||
type ViolationsRecord = Record<string, Violation[]>
|
||||
|
||||
@Component({ name: 'FormularioForm' })
|
||||
export default class FormularioForm extends Vue {
|
||||
@Model('input', { default: () => ({}) })
|
||||
public readonly state!: Record<string, unknown>
|
||||
|
||||
// Describes validation errors of whole form
|
||||
@Prop({ default: () => ({}) }) readonly fieldsErrors!: Record<string, string[]>
|
||||
@Prop({ default: () => ({}) }) readonly fieldsErrors!: ErrorsRecord
|
||||
// Only used on FormularioForm default slot
|
||||
@Prop({ default: () => ([]) }) readonly formErrors!: string[]
|
||||
|
||||
@ -48,7 +52,7 @@ export default class FormularioForm extends Vue {
|
||||
private registry: PathRegistry<FormularioField> = new PathRegistry()
|
||||
|
||||
// Local error messages are temporal, they wiped each resetValidation call
|
||||
private localFieldsErrors: Record<string, string[]> = {}
|
||||
private localFieldsErrors: ErrorsRecord = {}
|
||||
private localFormErrors: string[] = []
|
||||
|
||||
private get hasModel (): boolean {
|
||||
@ -75,41 +79,6 @@ export default class FormularioForm extends Vue {
|
||||
return [...this.formErrors, ...this.localFormErrors]
|
||||
}
|
||||
|
||||
@Watch('state', { deep: true })
|
||||
private onStateChange (values: Record<string, unknown>): void {
|
||||
if (this.hasModel && values && typeof values === 'object') {
|
||||
this.setValues(values)
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('fieldsErrorsComputed', { deep: true, immediate: true })
|
||||
private onFieldsErrorsChange (fieldsErrors: Record<string, string[]>): void {
|
||||
this.registry.forEach((field, path) => {
|
||||
field.setErrors(fieldsErrors[path] || [])
|
||||
})
|
||||
}
|
||||
|
||||
@Provide('__FormularioForm_getValue')
|
||||
private getValue (): Record<string, unknown> {
|
||||
return this.proxy
|
||||
}
|
||||
|
||||
created (): void {
|
||||
this.syncProxy()
|
||||
}
|
||||
|
||||
private onSubmit (): Promise<void> {
|
||||
return this.hasValidationErrors()
|
||||
.then(hasErrors => hasErrors ? undefined : clone(this.proxy))
|
||||
.then(data => {
|
||||
if (typeof data !== 'undefined') {
|
||||
this.$emit('submit', data)
|
||||
} else {
|
||||
this.$emit('error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Provide('__FormularioForm_register')
|
||||
private register (path: string, field: FormularioField): void {
|
||||
this.registry.add(path, field)
|
||||
@ -117,17 +86,13 @@ export default class FormularioForm extends Vue {
|
||||
const value = getNested(this.modelCopy, path)
|
||||
|
||||
if (!field.hasModel && this.modelIsDefined && value !== undefined) {
|
||||
// In the case that the form is carrying an initial value and the
|
||||
// element is not, set it directly.
|
||||
field.model = value
|
||||
} else if (field.hasModel && !shallowEquals(field.proxy, value)) {
|
||||
// In this case, the field is v-modeled or has an initial value and the
|
||||
// form has no value or a different value, so use the field value
|
||||
this.setFieldValueAndEmit(path, field.proxy)
|
||||
}
|
||||
|
||||
if (has(this.fieldsErrorsComputed, path)) {
|
||||
field.setErrors(this.fieldsErrorsComputed[path] || [])
|
||||
field.setErrors(this.fieldsErrorsComputed[path])
|
||||
}
|
||||
}
|
||||
|
||||
@ -141,18 +106,82 @@ export default class FormularioForm extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
@Provide('__FormularioForm_getState')
|
||||
private getState (): Record<string, unknown> {
|
||||
return this.proxy
|
||||
}
|
||||
|
||||
@Provide('__FormularioForm_set')
|
||||
private setFieldValueAndEmit (field: string, value: unknown): void {
|
||||
this.setFieldValue(field, value)
|
||||
this.$emit('input', { ...this.proxy })
|
||||
}
|
||||
|
||||
@Provide('__FormularioForm_emitValidation')
|
||||
private emitValidation (payload: ValidationEventPayload): void {
|
||||
this.$emit('validation', payload)
|
||||
}
|
||||
|
||||
private syncProxy (): void {
|
||||
if (this.modelIsDefined) {
|
||||
this.proxy = this.modelCopy
|
||||
@Watch('state', { deep: true })
|
||||
private onStateChange (state: Record<string, unknown>): void {
|
||||
if (this.hasModel && state && typeof state === 'object') {
|
||||
this.loadState(state)
|
||||
}
|
||||
}
|
||||
|
||||
setValues (state: Record<string, unknown>): void {
|
||||
@Watch('fieldsErrorsComputed', { deep: true, immediate: true })
|
||||
private onFieldsErrorsChange (fieldsErrors: Record<string, string[]>): void {
|
||||
this.registry.forEach((field, path) => {
|
||||
field.setErrors(fieldsErrors[path] || [])
|
||||
})
|
||||
}
|
||||
|
||||
public created (): void {
|
||||
this.syncProxy()
|
||||
}
|
||||
|
||||
public runValidation (): Promise<ViolationsRecord> {
|
||||
const violations: ViolationsRecord = {}
|
||||
const runs = this.registry.map((field: FormularioField, path: string) => {
|
||||
return field.runValidation().then(v => { violations[path] = v })
|
||||
})
|
||||
|
||||
return Promise.all(runs).then(() => violations)
|
||||
}
|
||||
|
||||
public hasValidationErrors (): Promise<boolean> {
|
||||
return this.runValidation().then(violations => {
|
||||
return Object.keys(violations).some(path => violations[path].length > 0)
|
||||
})
|
||||
}
|
||||
|
||||
public setErrors ({ fieldsErrors, formErrors }: { fieldsErrors?: ErrorsRecord; formErrors?: string[] }): void {
|
||||
this.localFieldsErrors = fieldsErrors || {}
|
||||
this.localFormErrors = formErrors || []
|
||||
}
|
||||
|
||||
public resetValidation (): void {
|
||||
this.localFieldsErrors = {}
|
||||
this.localFormErrors = []
|
||||
this.registry.forEach((field: FormularioField) => {
|
||||
field.resetValidation()
|
||||
})
|
||||
}
|
||||
|
||||
private onSubmit (): Promise<void> {
|
||||
return this.runValidation()
|
||||
.then(violations => {
|
||||
const hasErrors = Object.keys(violations).some(path => violations[path].length > 0)
|
||||
|
||||
if (!hasErrors) {
|
||||
this.$emit('submit', clone(this.proxy))
|
||||
} else {
|
||||
this.$emit('error', violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private loadState (state: Record<string, unknown>): void {
|
||||
const paths = Array.from(new Set([
|
||||
...Object.keys(state),
|
||||
...Object.keys(this.proxy),
|
||||
@ -187,7 +216,13 @@ export default class FormularioForm extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
setFieldValue (field: string, value: unknown): void {
|
||||
private syncProxy (): void {
|
||||
if (this.modelIsDefined) {
|
||||
this.proxy = this.modelCopy
|
||||
}
|
||||
}
|
||||
|
||||
private setFieldValue (field: string, value: unknown): void {
|
||||
if (value === undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [field]: value, ...proxy } = this.proxy
|
||||
@ -196,31 +231,5 @@ export default class FormularioForm extends Vue {
|
||||
setNested(this.proxy, field, value)
|
||||
}
|
||||
}
|
||||
|
||||
@Provide('__FormularioForm_set')
|
||||
setFieldValueAndEmit (field: string, value: unknown): void {
|
||||
this.setFieldValue(field, value)
|
||||
this.$emit('input', { ...this.proxy })
|
||||
}
|
||||
|
||||
setErrors ({ fieldsErrors, formErrors }: { fieldsErrors?: Record<string, string[]>; formErrors?: string[] }): void {
|
||||
this.localFieldsErrors = fieldsErrors || {}
|
||||
this.localFormErrors = formErrors || []
|
||||
}
|
||||
|
||||
hasValidationErrors (): Promise<boolean> {
|
||||
return Promise.all(this.registry.reduce((resolvers: Promise<boolean>[], field: FormularioField) => {
|
||||
resolvers.push(field.runValidation() && field.hasValidationErrors())
|
||||
return resolvers
|
||||
}, [])).then(results => results.some(hasErrors => hasErrors))
|
||||
}
|
||||
|
||||
resetValidation (): void {
|
||||
this.localFieldsErrors = {}
|
||||
this.localFormErrors = []
|
||||
this.registry.forEach((field: FormularioField) => {
|
||||
field.resetValidation()
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -59,24 +59,13 @@ export default class PathRegistry<T> {
|
||||
return this.registry.keys()
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over the registry.
|
||||
*/
|
||||
forEach (callback: (field: T, path: string) => void): void {
|
||||
this.registry.forEach((field, path) => {
|
||||
callback(field, path)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce the registry.
|
||||
* @param {function} callback
|
||||
* @param accumulator
|
||||
*/
|
||||
reduce<U> (callback: (accumulator: U, item: T, path: string) => U, accumulator: U): U {
|
||||
this.registry.forEach((item, path) => {
|
||||
accumulator = callback(accumulator, item, path)
|
||||
})
|
||||
return accumulator
|
||||
map<U> (mapper: (item: T, path: string) => U): U[] {
|
||||
return Array.from(this.registry.keys()).map(path => mapper(this.get(path) as T, path))
|
||||
}
|
||||
}
|
||||
|
@ -5,10 +5,10 @@ export interface Validator {
|
||||
}
|
||||
|
||||
export interface Violation {
|
||||
message: string;
|
||||
rule: string|null;
|
||||
args: any[];
|
||||
context: ValidationContext|null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationRuleFn {
|
||||
|
@ -136,7 +136,7 @@ describe('FormularioForm', () => {
|
||||
test('resolves submitted form values to an object', async () => {
|
||||
const wrapper = mount(FormularioForm, {
|
||||
slots: {
|
||||
default: '<FormularioField name="fieldName" validation="required" value="Justin" />'
|
||||
default: '<FormularioField name="name" validation="required" value="Justin" />'
|
||||
},
|
||||
})
|
||||
|
||||
@ -145,10 +145,53 @@ describe('FormularioForm', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('submit')).toEqual([
|
||||
[{ fieldName: 'Justin' }],
|
||||
[{ name: 'Justin' }],
|
||||
])
|
||||
})
|
||||
|
||||
test('resolves runValidation', async () => {
|
||||
const wrapper = mount(FormularioForm, {
|
||||
slots: {
|
||||
default: `
|
||||
<div>
|
||||
<FormularioField name="address.street" validation="required" />
|
||||
<FormularioField name="address.building" validation="required" />
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const violations = await wrapper.vm.runValidation()
|
||||
const state = {
|
||||
address: {
|
||||
street: null,
|
||||
},
|
||||
}
|
||||
|
||||
expect(violations).toEqual({
|
||||
'address.street': [{
|
||||
message: expect.any(String),
|
||||
rule: 'required',
|
||||
args: [],
|
||||
context: {
|
||||
name: 'address.street',
|
||||
value: null,
|
||||
formValues: state,
|
||||
},
|
||||
}],
|
||||
'address.building': [{
|
||||
message: expect.any(String),
|
||||
rule: 'required',
|
||||
args: [],
|
||||
context: {
|
||||
name: 'address.building',
|
||||
value: '',
|
||||
formValues: state,
|
||||
},
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves hasValidationErrors to true', async () => {
|
||||
const wrapper = mount(FormularioForm, {
|
||||
slots: {
|
||||
@ -160,10 +203,8 @@ describe('FormularioForm', () => {
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const emitted = wrapper.emitted()
|
||||
|
||||
expect(emitted['error']).toBeTruthy()
|
||||
expect(emitted['error'].length).toBe(1)
|
||||
expect(wrapper.emitted('error')).toBeTruthy()
|
||||
expect(wrapper.emitted('error').length).toBe(1)
|
||||
})
|
||||
|
||||
describe('allows setting fields errors', () => {
|
||||
|
Loading…
Reference in New Issue
Block a user