1
0
mirror of synced 2024-11-22 05:16:05 +03:00

refactor: Simplified logic of field components registry, moved value set logic from registry to form component

This commit is contained in:
Zaytsev Kirill 2021-05-23 01:44:02 +03:00
parent 8144c27c69
commit 4c3274e621
7 changed files with 192 additions and 240 deletions

View File

@ -73,7 +73,7 @@ export default class FormularioField extends Vue {
validator: behavior => Object.values(VALIDATION_BEHAVIOR).includes(behavior)
}) validationBehavior!: string
// Affects only observing & setting of local errors
// Affects only setting of local errors
@Prop({ default: false }) errorsDisabled!: boolean
@Prop({ default: () => <U, T>(value: U|Empty): U|T|Empty => value }) modelGetConverter!: ModelGetConverter
@ -87,10 +87,17 @@ export default class FormularioField extends Vue {
private validationRun: Promise<Violation[]> = Promise.resolve([])
public get fullQualifiedName (): string {
public get fullPath (): string {
return this.__Formulario_path !== '' ? `${this.__Formulario_path}.${this.name}` : this.name
}
/**
* Determines if this formulario element is v-modeled or not.
*/
get hasModel (): boolean {
return has(this.$options.propsData || {}, 'value')
}
private get model (): unknown {
const model = this.hasModel ? 'value' : 'proxy'
return this.modelGetConverter(this[model])
@ -112,7 +119,7 @@ export default class FormularioField extends Vue {
private get context (): FormularioFieldContext<unknown> {
return Object.defineProperty({
name: this.fullQualifiedName,
name: this.fullPath,
runValidation: this.runValidation.bind(this),
violations: this.violations,
errors: this.localErrors,
@ -141,13 +148,6 @@ export default class FormularioField extends Vue {
return messages
}
/**
* Determines if this formulario element is v-modeled or not.
*/
private get hasModel (): boolean {
return has(this.$options.propsData || {}, 'value')
}
@Watch('proxy')
private onProxyChange (newValue: unknown, oldValue: unknown): void {
if (!this.hasModel && !shallowEquals(newValue, oldValue)) {
@ -170,7 +170,7 @@ export default class FormularioField extends Vue {
created (): void {
this.initProxy()
if (typeof this.__FormularioForm_register === 'function') {
this.__FormularioForm_register(this.fullQualifiedName, this)
this.__FormularioForm_register(this.fullPath, this)
}
if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
this.runValidation()
@ -179,7 +179,7 @@ export default class FormularioField extends Vue {
beforeDestroy (): void {
if (typeof this.__FormularioForm_unregister === 'function') {
this.__FormularioForm_unregister(this.fullQualifiedName)
this.__FormularioForm_unregister(this.fullPath)
}
}

View File

@ -24,7 +24,7 @@ export default class FormularioFieldGroup extends Vue {
readonly isArrayItem!: boolean
@Provide('__Formulario_path')
get groupPath (): string {
get fullPath (): string {
if (this.isArrayItem) {
return `${this.__Formulario_path}[${this.name}]`
}
@ -33,7 +33,7 @@ export default class FormularioFieldGroup extends Vue {
return this.name
}
return `${this.path}.${this.name}`
return `${this.__Formulario_path}.${this.name}`
}
}
</script>

View File

@ -22,7 +22,7 @@ import {
shallowEquals,
} from '@/utils'
import FormularioFormRegistry from '@/FormularioFormRegistry'
import PathRegistry from '@/PathRegistry'
import FormularioField from '@/FormularioField.vue'
@ -45,7 +45,7 @@ export default class FormularioForm extends Vue {
public proxy: Record<string, unknown> = {}
private registry: FormularioFormRegistry = new FormularioFormRegistry(this)
private registry: PathRegistry<FormularioField> = new PathRegistry()
// Local error messages are temporal, they wiped each resetValidation call
private localFormErrors: string[] = []
@ -85,13 +85,13 @@ export default class FormularioForm extends Vue {
@Watch('mergedFieldErrors', { deep: true, immediate: true })
onMergedFieldErrorsChange (errors: Record<string, string[]>): void {
this.registry.forEach((vm, path) => {
vm.setErrors(errors[path] || [])
this.registry.forEach((field, path) => {
field.setErrors(errors[path] || [])
})
}
created (): void {
this.initProxy()
this.syncProxy()
}
@Provide('__FormularioForm_getValue')
@ -112,58 +112,77 @@ export default class FormularioForm extends Vue {
}
@Provide('__FormularioForm_emitValidation')
onFormularioFieldValidation (payload: ValidationEventPayload): void {
private emitValidation (payload: ValidationEventPayload): void {
this.$emit('validation', payload)
}
@Provide('__FormularioForm_register')
private register (field: string, vm: FormularioField): void {
this.registry.add(field, vm)
private register (path: string, field: FormularioField): void {
this.registry.add(path, field)
if (has(this.mergedFieldErrors, field)) {
vm.setErrors(this.mergedFieldErrors[field] || [])
const value = getNested(this.initialValues, path)
if (!field.hasModel && this.hasInitialValue && value !== undefined) {
// In the case that the form is carrying an initial value and the
// element is not, set it directly.
// @ts-ignore
field.context.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.mergedFieldErrors, path)) {
field.setErrors(this.mergedFieldErrors[path] || [])
}
}
@Provide('__FormularioForm_unregister')
private unregister (field: string): void {
if (this.registry.has(field)) {
this.registry.remove(field)
private unregister (path: string): void {
if (this.registry.has(path)) {
this.registry.remove(path)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [path]: _, ...newProxy } = this.proxy
this.proxy = newProxy
}
}
initProxy (): void {
syncProxy (): void {
if (this.hasInitialValue) {
this.proxy = this.initialValues
}
}
setValues (values: Record<string, unknown>): void {
const keys = Array.from(new Set([...Object.keys(values), ...Object.keys(this.proxy)]))
setValues (state: Record<string, unknown>): void {
const paths = Array.from(new Set([
...Object.keys(state),
...Object.keys(this.proxy),
]))
let proxyHasChanges = false
keys.forEach(field => {
if (!this.registry.hasNested(field)) {
paths.forEach(path => {
if (!this.registry.hasSubset(path)) {
return
}
this.registry.getNested(field).forEach((_, fqn) => {
const $field = this.registry.get(fqn) as FormularioField
const oldValue = getNested(this.proxy, fqn)
const newValue = getNested(values, fqn)
this.registry.getSubset(path).forEach((field, path) => {
const oldValue = getNested(this.proxy, path)
const newValue = getNested(state, path)
if (!shallowEquals(newValue, oldValue)) {
this.setFieldValue(fqn, newValue)
this.setFieldValue(path, newValue)
proxyHasChanges = true
}
if (!shallowEquals(newValue, $field.proxy)) {
$field.context.model = newValue
if (!shallowEquals(newValue, field.proxy)) {
field.context.model = newValue
}
})
})
this.initProxy()
this.syncProxy()
if (proxyHasChanges) {
this.$emit('input', { ...this.proxy })

View File

@ -1,142 +0,0 @@
import { getNested, has, shallowEquals } from '@/utils'
import FormularioField from '@/FormularioField.vue'
import FormularioForm from '@/FormularioForm.vue'
/**
* Component registry with inherent depth to handle complex nesting. This is
* important for features such as grouped fields.
*/
export default class FormularioFormRegistry {
private ctx: FormularioForm
private registry: Map<string, FormularioField>
/**
* Create a new registry of components.
* @param {FormularioForm} ctx The host vm context of the registry.
*/
constructor (ctx: FormularioForm) {
this.registry = new Map()
this.ctx = ctx
}
/**
* Fully register a component.
* @param {string} field name of the field.
* @param {FormularioForm} component the actual component instance.
*/
add (field: string, component: FormularioField): void {
if (this.registry.has(field)) {
return
}
this.registry.set(field, component)
// @ts-ignore
const value = getNested(this.ctx.initialValues, field)
const hasModel = has(component.$options.propsData || {}, 'value')
// @ts-ignore
if (!hasModel && this.ctx.hasInitialValue && value !== undefined) {
// In the case that the form is carrying an initial value and the
// element is not, set it directly.
// @ts-ignore
component.context.model = value
// @ts-ignore
} else if (hasModel && !shallowEquals(component.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
// @ts-ignore
this.ctx.setFieldValueAndEmit(field, component.proxy)
}
}
/**
* Remove an item from the registry.
*/
remove (name: string): void {
this.registry.delete(name)
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [name]: value, ...newProxy } = this.ctx.proxy
// @ts-ignore
this.ctx.proxy = newProxy
}
/**
* Check if the registry has the given key.
*/
has (key: string): boolean {
return this.registry.has(key)
}
/**
* Check if the registry has elements, that equals or nested given key
*/
hasNested (key: string): boolean {
for (const i of this.registry.keys()) {
if (i === key || i.includes(key + '.')) {
return true
}
}
return false
}
/**
* Get a particular registry value.
*/
get (key: string): FormularioField | undefined {
return this.registry.get(key)
}
/**
* Get registry value for key or nested to given key
*/
getNested (key: string): Map<string, FormularioField> {
const result = new Map()
for (const i of this.registry.keys()) {
const objectKey = key + '.'
const arrayKey = key + '['
if (
i === key ||
i.substring(0, objectKey.length) === objectKey ||
i.substring(0, arrayKey.length) === arrayKey
) {
result.set(i, this.registry.get(i))
}
}
return result
}
/**
* Iterate over the registry.
*/
forEach (callback: (component: FormularioField, field: string) => void): void {
this.registry.forEach((component, field) => {
callback(component, field)
})
}
/**
* Return the keys of the registry.
*/
keys (): string[] {
return Array.from(this.registry.keys())
}
/**
* Reduce the registry.
* @param {function} callback
* @param accumulator
*/
reduce<U> (callback: Function, accumulator: U): U {
this.registry.forEach((component, field) => {
accumulator = callback(accumulator, component, field)
})
return accumulator
}
}

82
src/PathRegistry.ts Normal file
View File

@ -0,0 +1,82 @@
/**
* @internal
*/
export default class PathRegistry<T> {
private registry: Map<string, T>
constructor () {
this.registry = new Map()
}
has (path: string): boolean {
return this.registry.has(path)
}
hasSubset (path: string): boolean {
for (const itemPath of this.registry.keys()) {
if (itemPath === path || itemPath.includes(path + '.')) {
return true
}
}
return false
}
get (path: string): T | undefined {
return this.registry.get(path)
}
/**
* Returns registry subset by given path - field & descendants
*/
getSubset (path: string): PathRegistry<T> {
const subset: PathRegistry<T> = new PathRegistry()
for (const itemPath of this.registry.keys()) {
if (
itemPath === path ||
itemPath.startsWith(path + '.') ||
itemPath.startsWith(path + '[')
) {
subset.add(itemPath, this.registry.get(itemPath) as T)
}
}
return subset
}
add (path: string, item: T): void {
if (!this.registry.has(path)) {
this.registry.set(path, item)
}
}
remove (path: string): void {
this.registry.delete(path)
}
paths (): IterableIterator<string> {
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
}
}

View File

@ -38,63 +38,6 @@ describe('FormularioForm', () => {
expect(spy).toHaveBeenCalled()
})
it('Adds subcomponents to the registry', () => {
const wrapper = mount(FormularioForm, {
propsData: { state: {} },
slots: {
default: `
<FormularioField name="sub1" />
<FormularioField name="sub2" />
`
}
})
expect(wrapper.vm['registry'].keys()).toEqual(['sub1', 'sub2'])
})
it('Removes subcomponents from the registry', async () => {
const wrapper = mount({
data: () => ({ active: true }),
template: `
<FormularioForm>
<FormularioField v-if="active" name="sub1" />
<FormularioField name="sub2" />
</FormularioForm>
`
})
await flushPromises()
expect(wrapper.findComponent(FormularioForm).vm['registry'].keys()).toEqual(['sub1', 'sub2'])
wrapper.setData({ active: false })
await flushPromises()
expect(wrapper.findComponent(FormularioForm).vm['registry'].keys()).toEqual(['sub2'])
})
it('Getting nested fields from registry', async () => {
const wrapper = mount({
data: () => ({ active: true, nested: { groups: { value: 'value' } }, groups: [{ name: 'group1' }, { name: 'group2' }] }),
template: `
<FormularioForm>
<FormularioField name="sub1" />
<FormularioField name="sub2" />
<FormularioField name="nested.groups.value" />
<FormularioField name="groups">
<FormularioFieldGroup :name="'groups[' + index + ']'" v-for="(_, index) in groups" :key="index">
<FormularioField name="name" />
</FormularioFieldGroup>
</FormularioField>
</FormularioForm>
`
})
await flushPromises()
expect(Array.from(wrapper.findComponent(FormularioForm).vm.registry.getNested('sub1').keys())).toEqual(['sub1'])
expect(Array.from(wrapper.findComponent(FormularioForm).vm.registry.getNested('groups').keys()))
.toEqual(['groups', 'groups[0].name', 'groups[1].name'])
wrapper.setData({ active: true, groups: [{ name: 'group1' }] })
await flushPromises()
expect(Array.from(wrapper.findComponent(FormularioForm).vm.registry.getNested('groups').keys()))
.toEqual(['groups', 'groups[0].name'])
})
it('Can set a fields initial value', async () => {
const wrapper = mount(FormularioForm, {
propsData: { state: { test: 'Has initial value' } },

View File

@ -0,0 +1,50 @@
import PathRegistry from '@/PathRegistry'
describe('PathRegistry', () => {
test ('subset structure', () => {
const registry = new PathRegistry()
const paths = path => Array.from(registry.getSubset(path).paths())
registry.add('name', null)
registry.add('address', [])
registry.add('address[0]', {})
registry.add('address[0].street', 'Baker Street')
registry.add('address[0].building', '221b')
registry.add('address[1]', {})
registry.add('address[1].street', '')
registry.add('address[1].building', '')
expect(paths('name')).toEqual(['name'])
expect(paths('address')).toEqual([
'address',
'address[0]',
'address[0].street',
'address[0].building',
'address[1]',
'address[1].street',
'address[1].building',
])
expect(paths('address[1]')).toEqual([
'address[1]',
'address[1].street',
'address[1].building',
])
registry.remove('address[1]')
expect(paths('address')).toEqual([
'address',
'address[0]',
'address[0].street',
'address[0].building',
'address[1].street',
'address[1].building',
])
expect(paths('address[1]')).toEqual([
'address[1].street',
'address[1].building',
])
})
})