1
0
mirror of synced 2024-11-22 05:16:05 +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: () => (): 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 = []
}

View File

@ -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>

View File

@ -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))
}
}

View File

@ -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 {

View File

@ -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', () => {