1
0
mirror of synced 2024-11-25 23:06:02 +03:00

feat: FormularioForm - added ::runValidation() method to run entire form validation manually and return all form violations

This commit is contained in:
Zaytsev Kirill 2021-05-25 01:47:44 +03:00
parent 4e05844e73
commit aee0dc977a
5 changed files with 170 additions and 129 deletions

View File

@ -56,7 +56,7 @@ export default class FormularioField extends Vue {
@Inject({ default: undefined }) __FormularioForm_unregister!: Function|undefined @Inject({ default: undefined }) __FormularioForm_unregister!: Function|undefined
@Inject({ default: () => (): Record<string, unknown> => ({}) }) @Inject({ default: () => (): Record<string, unknown> => ({}) })
__FormularioForm_getValue!: () => Record<string, unknown> __FormularioForm_getState!: () => Record<string, unknown>
@Model('input', { default: '' }) value!: 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: () => <U, T>(value: U|Empty): U|T|Empty => value }) modelGetConverter!: ModelGetConverter
@Prop({ default: () => <T, U>(value: U|T): U|T => value }) modelSetConverter!: ModelSetConverter @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[] = [] private localErrors: string[] = []
@ -148,10 +148,17 @@ export default class FormularioField extends Vue {
return messages return messages
} }
@Watch('value')
private onValueChange (newValue: unknown, oldValue: unknown): void {
if (this.hasModel && !shallowEquals(newValue, oldValue)) {
this.model = newValue
}
}
@Watch('proxy') @Watch('proxy')
private onProxyChange (newValue: unknown, oldValue: unknown): void { private onProxyChange (newValue: unknown, oldValue: unknown): void {
if (!this.hasModel && !shallowEquals(newValue, oldValue)) { if (!this.hasModel && !shallowEquals(newValue, oldValue)) {
this.context.model = newValue this.model = newValue
} }
if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) { if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
this.runValidation() this.runValidation()
@ -160,54 +167,42 @@ export default class FormularioField extends Vue {
} }
} }
@Watch('value') /**
private onValueChange (newValue: unknown, oldValue: unknown): void { * @internal
if (this.hasModel && !shallowEquals(newValue, oldValue)) { */
this.context.model = newValue public created (): void {
} if (!shallowEquals(this.model, this.proxy)) {
this.model = this.proxy
} }
created (): void {
this.initProxy()
if (typeof this.__FormularioForm_register === 'function') { if (typeof this.__FormularioForm_register === 'function') {
this.__FormularioForm_register(this.fullPath, this) this.__FormularioForm_register(this.fullPath, this)
} }
if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) { if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
this.runValidation() this.runValidation()
} }
} }
beforeDestroy (): void { /**
* @internal
*/
public beforeDestroy (): void {
if (typeof this.__FormularioForm_unregister === 'function') { if (typeof this.__FormularioForm_unregister === 'function') {
this.__FormularioForm_unregister(this.fullPath) this.__FormularioForm_unregister(this.fullPath)
} }
} }
private getInitialValue (): unknown { public runValidation (): Promise<Violation[]> {
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[]> {
this.validationRun = this.validate().then(violations => { this.validationRun = this.validate().then(violations => {
const validationChanged = !shallowEquals(violations, this.violations) const validationChanged = !shallowEquals(violations, this.violations)
this.violations = violations this.violations = violations
if (validationChanged) { if (validationChanged) {
const payload = { this.emitValidation({
name: this.context.name, name: this.fullPath,
violations: this.violations, violations: this.violations,
} })
this.$emit('validation', payload)
if (typeof this.__FormularioForm_emitValidation === 'function') {
this.__FormularioForm_emitValidation(payload)
}
} }
return this.violations return this.violations
@ -216,7 +211,7 @@ export default class FormularioField extends Vue {
return this.validationRun return this.validationRun
} }
validate (): Promise<Violation[]> { private validate (): Promise<Violation[]> {
return validate(processConstraints( return validate(processConstraints(
this.validation, this.validation,
this.$formulario.getRules(this.normalizedValidationRules), this.$formulario.getRules(this.normalizedValidationRules),
@ -224,11 +219,18 @@ export default class FormularioField extends Vue {
), { ), {
value: this.context.model, value: this.context.model,
name: this.context.name, 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 => { return new Promise(resolve => {
this.$nextTick(() => { this.$nextTick(() => {
this.validationRun.then(() => resolve(this.violations.length > 0)) this.validationRun.then(() => resolve(this.violations.length > 0))
@ -239,7 +241,7 @@ export default class FormularioField extends Vue {
/** /**
* @internal * @internal
*/ */
setErrors (errors: string[]): void { public setErrors (errors: string[]): void {
if (!this.errorsDisabled) { if (!this.errorsDisabled) {
this.localErrors = arrayify(errors) as string[] this.localErrors = arrayify(errors) as string[]
} }
@ -248,7 +250,7 @@ export default class FormularioField extends Vue {
/** /**
* @internal * @internal
*/ */
resetValidation (): void { public resetValidation (): void {
this.localErrors = [] this.localErrors = []
this.violations = [] this.violations = []
} }

View File

@ -28,18 +28,22 @@ import FormularioField from '@/FormularioField.vue'
import { Violation } from '@/validation/validator' import { Violation } from '@/validation/validator'
type ErrorsRecord = Record<string, string[]>
type ValidationEventPayload = { type ValidationEventPayload = {
name: string; name: string;
violations: Violation[]; violations: Violation[];
} }
type ViolationsRecord = Record<string, Violation[]>
@Component({ name: 'FormularioForm' }) @Component({ name: 'FormularioForm' })
export default class FormularioForm extends Vue { export default class FormularioForm extends Vue {
@Model('input', { default: () => ({}) }) @Model('input', { default: () => ({}) })
public readonly state!: Record<string, unknown> public readonly state!: Record<string, unknown>
// Describes validation errors of whole form // Describes validation errors of whole form
@Prop({ default: () => ({}) }) readonly fieldsErrors!: Record<string, string[]> @Prop({ default: () => ({}) }) readonly fieldsErrors!: ErrorsRecord
// Only used on FormularioForm default slot // Only used on FormularioForm default slot
@Prop({ default: () => ([]) }) readonly formErrors!: string[] @Prop({ default: () => ([]) }) readonly formErrors!: string[]
@ -48,7 +52,7 @@ export default class FormularioForm extends Vue {
private registry: PathRegistry<FormularioField> = new PathRegistry() private registry: PathRegistry<FormularioField> = new PathRegistry()
// Local error messages are temporal, they wiped each resetValidation call // Local error messages are temporal, they wiped each resetValidation call
private localFieldsErrors: Record<string, string[]> = {} private localFieldsErrors: ErrorsRecord = {}
private localFormErrors: string[] = [] private localFormErrors: string[] = []
private get hasModel (): boolean { private get hasModel (): boolean {
@ -75,41 +79,6 @@ export default class FormularioForm extends Vue {
return [...this.formErrors, ...this.localFormErrors] 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') @Provide('__FormularioForm_register')
private register (path: string, field: FormularioField): void { private register (path: string, field: FormularioField): void {
this.registry.add(path, field) this.registry.add(path, field)
@ -117,17 +86,13 @@ export default class FormularioForm extends Vue {
const value = getNested(this.modelCopy, path) const value = getNested(this.modelCopy, path)
if (!field.hasModel && this.modelIsDefined && value !== undefined) { 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 field.model = value
} else if (field.hasModel && !shallowEquals(field.proxy, 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) this.setFieldValueAndEmit(path, field.proxy)
} }
if (has(this.fieldsErrorsComputed, path)) { 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') @Provide('__FormularioForm_emitValidation')
private emitValidation (payload: ValidationEventPayload): void { private emitValidation (payload: ValidationEventPayload): void {
this.$emit('validation', payload) this.$emit('validation', payload)
} }
private syncProxy (): void { @Watch('state', { deep: true })
if (this.modelIsDefined) { private onStateChange (state: Record<string, unknown>): void {
this.proxy = this.modelCopy 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([ const paths = Array.from(new Set([
...Object.keys(state), ...Object.keys(state),
...Object.keys(this.proxy), ...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) { if (value === undefined) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [field]: value, ...proxy } = this.proxy const { [field]: value, ...proxy } = this.proxy
@ -196,31 +231,5 @@ export default class FormularioForm extends Vue {
setNested(this.proxy, field, value) 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> </script>

View File

@ -59,24 +59,13 @@ export default class PathRegistry<T> {
return this.registry.keys() return this.registry.keys()
} }
/**
* Iterate over the registry.
*/
forEach (callback: (field: T, path: string) => void): void { forEach (callback: (field: T, path: string) => void): void {
this.registry.forEach((field, path) => { this.registry.forEach((field, path) => {
callback(field, path) callback(field, path)
}) })
} }
/** map<U> (mapper: (item: T, path: string) => U): U[] {
* Reduce the registry. return Array.from(this.registry.keys()).map(path => mapper(this.get(path) as T, path))
* @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
} }
} }

View File

@ -5,10 +5,10 @@ export interface Validator {
} }
export interface Violation { export interface Violation {
message: string;
rule: string|null; rule: string|null;
args: any[]; args: any[];
context: ValidationContext|null; context: ValidationContext|null;
message: string;
} }
export interface ValidationRuleFn { export interface ValidationRuleFn {

View File

@ -136,7 +136,7 @@ describe('FormularioForm', () => {
test('resolves submitted form values to an object', async () => { test('resolves submitted form values to an object', async () => {
const wrapper = mount(FormularioForm, { const wrapper = mount(FormularioForm, {
slots: { 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() await flushPromises()
expect(wrapper.emitted('submit')).toEqual([ 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 () => { test('resolves hasValidationErrors to true', async () => {
const wrapper = mount(FormularioForm, { const wrapper = mount(FormularioForm, {
slots: { slots: {
@ -160,10 +203,8 @@ describe('FormularioForm', () => {
await flushPromises() await flushPromises()
const emitted = wrapper.emitted() expect(wrapper.emitted('error')).toBeTruthy()
expect(wrapper.emitted('error').length).toBe(1)
expect(emitted['error']).toBeTruthy()
expect(emitted['error'].length).toBe(1)
}) })
describe('allows setting fields errors', () => { describe('allows setting fields errors', () => {