1
0
mirror of synced 2024-11-24 06:16:25 +03:00

refactor: State management logic & tests

This commit is contained in:
Zaytsev Kirill 2021-06-10 17:01:50 +03:00
parent 9868d99c19
commit a1476d9986
9 changed files with 143 additions and 119 deletions

View File

@ -26,7 +26,7 @@ export default class Formulario {
private readonly registry: Map<string, FormularioFormInterface> private readonly registry: Map<string, FormularioFormInterface>
constructor (options?: FormularioOptions) { public constructor (options?: FormularioOptions) {
this.registry = new Map() this.registry = new Map()
this.validationRules = validationRules this.validationRules = validationRules
@ -38,7 +38,7 @@ export default class Formulario {
/** /**
* Given a set of options, apply them to the pre-existing options. * Given a set of options, apply them to the pre-existing options.
*/ */
extend (extendWith: FormularioOptions): Formulario { public extend (extendWith: FormularioOptions): Formulario {
if (typeof extendWith === 'object') { if (typeof extendWith === 'object') {
this.validationRules = merge(this.validationRules, extendWith.validationRules || {}) this.validationRules = merge(this.validationRules, extendWith.validationRules || {})
this.validationMessages = merge(this.validationMessages, extendWith.validationMessages || {}) this.validationMessages = merge(this.validationMessages, extendWith.validationMessages || {})
@ -47,7 +47,7 @@ export default class Formulario {
throw new Error(`[Formulario]: Formulario.extend(): should be passed an object (was ${typeof extendWith})`) throw new Error(`[Formulario]: Formulario.extend(): should be passed an object (was ${typeof extendWith})`)
} }
runValidation (id: string): Promise<ViolationsRecord> { public runValidation (id: string): Promise<ViolationsRecord> {
if (!this.registry.has(id)) { if (!this.registry.has(id)) {
throw new Error(`[Formulario]: Formulario.runValidation(): no forms with id "${id}"`) throw new Error(`[Formulario]: Formulario.runValidation(): no forms with id "${id}"`)
} }
@ -57,7 +57,7 @@ export default class Formulario {
return form.runValidation() return form.runValidation()
} }
resetValidation (id: string): void { public resetValidation (id: string): void {
if (!this.registry.has(id)) { if (!this.registry.has(id)) {
return return
} }
@ -71,7 +71,7 @@ export default class Formulario {
* Used by forms instances to add themselves into a registry * Used by forms instances to add themselves into a registry
* @internal * @internal
*/ */
register (id: string, form: FormularioFormInterface): void { public register (id: string, form: FormularioFormInterface): void {
if (this.registry.has(id)) { if (this.registry.has(id)) {
throw new Error(`[Formulario]: Formulario.register(): id "${id}" is already in use`) throw new Error(`[Formulario]: Formulario.register(): id "${id}" is already in use`)
} }
@ -83,7 +83,7 @@ export default class Formulario {
* Used by forms instances to remove themselves from a registry * Used by forms instances to remove themselves from a registry
* @internal * @internal
*/ */
unregister (id: string): void { public unregister (id: string): void {
if (this.registry.has(id)) { if (this.registry.has(id)) {
this.registry.delete(id) this.registry.delete(id)
} }
@ -93,7 +93,7 @@ export default class Formulario {
* Get validation rules by merging any passed in with global rules. * Get validation rules by merging any passed in with global rules.
* @internal * @internal
*/ */
getRules (extendWith: Record<string, ValidationRuleFn> = {}): Record<string, ValidationRuleFn> { public getRules (extendWith: Record<string, ValidationRuleFn> = {}): Record<string, ValidationRuleFn> {
return merge(this.validationRules, extendWith) return merge(this.validationRules, extendWith)
} }
@ -101,7 +101,7 @@ export default class Formulario {
* Get validation messages by merging any passed in with global messages. * Get validation messages by merging any passed in with global messages.
* @internal * @internal
*/ */
getMessages (vm: Vue, extendWith: Record<string, ValidationMessageI18NFn|string>): Record<string, ValidationMessageFn> { public getMessages (vm: Vue, extendWith: Record<string, ValidationMessageI18NFn|string>): Record<string, ValidationMessageFn> {
const raw = merge(this.validationMessages || {}, extendWith) const raw = merge(this.validationMessages || {}, extendWith)
const messages: Record<string, ValidationMessageFn> = {} const messages: Record<string, ValidationMessageFn> = {}

View File

@ -15,11 +15,12 @@ import {
} from 'vue-property-decorator' } from 'vue-property-decorator'
import { import {
clone, clone,
getNested, get,
has, has,
merge, merge,
setNested, set,
shallowEquals, shallowEquals,
unset,
} from '@/utils' } from '@/utils'
import PathRegistry from '@/PathRegistry' import PathRegistry from '@/PathRegistry'
@ -86,10 +87,15 @@ export default class FormularioForm extends Vue {
private register (path: string, field: FormularioFieldInterface): void { private register (path: string, field: FormularioFieldInterface): void {
this.registry.add(path, field) this.registry.add(path, field)
const value = getNested(this.modelCopy, path) const value = get(this.modelCopy, path)
if (!field.hasModel && this.modelIsDefined && value !== undefined) { if (!field.hasModel && this.modelIsDefined) {
field.model = value if (value !== undefined) {
field.model = value
} else {
this.setFieldValue(path, null)
this.emitInput()
}
} else if (field.hasModel && !shallowEquals(field.proxy, value)) { } else if (field.hasModel && !shallowEquals(field.proxy, value)) {
this.setFieldValue(path, field.proxy) this.setFieldValue(path, field.proxy)
this.emitInput() this.emitInput()
@ -116,13 +122,11 @@ export default class FormularioForm extends Vue {
} }
@Provide('__FormularioForm_set') @Provide('__FormularioForm_set')
private setFieldValue (field: string, value: unknown): void { private setFieldValue (path: string, value: unknown): void {
if (value === undefined) { if (value === undefined) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars this.proxy = unset(this.proxy, path) as Record<string, unknown>
const { [field]: value, ...proxy } = this.proxy
this.proxy = proxy
} else { } else {
setNested(this.proxy, field, value) this.proxy = set(this.proxy, path, value) as Record<string, unknown>
} }
} }
@ -214,8 +218,8 @@ export default class FormularioForm extends Vue {
} }
this.registry.getSubset(path).forEach((field, path) => { this.registry.getSubset(path).forEach((field, path) => {
const oldValue = getNested(this.proxy, path) const oldValue = get(this.proxy, path, null)
const newValue = getNested(state, path) const newValue = get(state, path, null)
if (!shallowEquals(newValue, oldValue)) { if (!shallowEquals(newValue, oldValue)) {
this.setFieldValue(path, newValue) this.setFieldValue(path, newValue)

View File

@ -7,6 +7,7 @@ import FormularioFieldGroup from '@/FormularioFieldGroup.vue'
import FormularioForm from '@/FormularioForm.vue' import FormularioForm from '@/FormularioForm.vue'
export default { export default {
Formulario,
install (Vue: VueConstructor, options?: FormularioOptions): void { install (Vue: VueConstructor, options?: FormularioOptions): void {
Vue.component('FormularioField', FormularioField) Vue.component('FormularioField', FormularioField)
Vue.component('FormularioFieldGroup', FormularioFieldGroup) Vue.component('FormularioFieldGroup', FormularioFieldGroup)

View File

@ -7,10 +7,10 @@ const extractIntOrNaN = (value: string): number => {
return numeric.toString() === value ? numeric : NaN return numeric.toString() === value ? numeric : NaN
} }
const extractPath = (field: string): string[] => { const extractPath = (raw: string): string[] => {
const path = [] as string[] const path = [] as string[]
field.split('.').forEach(key => { raw.split('.').forEach(key => {
if (/(.*)\[(\d+)]$/.test(key)) { if (/(.*)\[(\d+)]$/.test(key)) {
path.push(...key.substr(0, key.length - 1).split('[').filter(k => k.length)) path.push(...key.substr(0, key.length - 1).split('[').filter(k => k.length))
} else { } else {
@ -21,18 +21,11 @@ const extractPath = (field: string): string[] => {
return path return path
} }
const unsetInRecord = (record: Record<string, unknown>, prop: string): Record<string, unknown> => { export function get (state: unknown, rawOrPath: string|string[], fallback: unknown = undefined): unknown {
// eslint-disable-next-line @typescript-eslint/no-unused-vars const path = typeof rawOrPath === 'string' ? extractPath(rawOrPath) : rawOrPath
const { [prop]: _, ...copy } = record
return copy
}
export function get (state: unknown, fieldOrPath: string|string[]): unknown {
const path = typeof fieldOrPath === 'string' ? extractPath(fieldOrPath) : fieldOrPath
if (isScalar(state) || path.length === 0) { if (isScalar(state) || path.length === 0) {
return undefined return fallback
} }
const key = path.shift() as string const key = path.shift() as string
@ -40,7 +33,7 @@ export function get (state: unknown, fieldOrPath: string|string[]): unknown {
if (!isNaN(index)) { if (!isNaN(index)) {
if (Array.isArray(state) && index >= 0 && index < state.length) { if (Array.isArray(state) && index >= 0 && index < state.length) {
return path.length === 0 ? state[index] : get(state[index], path) return path.length === 0 ? state[index] : get(state[index], path, fallback)
} }
return undefined return undefined
@ -49,18 +42,54 @@ export function get (state: unknown, fieldOrPath: string|string[]): unknown {
if (has(state as Record<string, unknown>, key)) { if (has(state as Record<string, unknown>, key)) {
const values = state as Record<string, unknown> const values = state as Record<string, unknown>
return path.length === 0 ? values[key] : get(values[key], path) return path.length === 0 ? values[key] : get(values[key], path, fallback)
} }
return undefined return undefined
} }
export function unset (state: unknown, fieldOrPath: string|string[]): unknown { export function set (state: unknown, rawOrPath: string|string[], value: unknown): unknown {
const path = typeof rawOrPath === 'string' ? extractPath(rawOrPath) : rawOrPath
if (path.length === 0) {
return value
}
const key = path.shift() as string
const index = extractIntOrNaN(key)
if (!isRecordLike(state)) {
return set(!isNaN(index) ? [] : {}, [key, ...path], value)
}
if (!isNaN(index) && Array.isArray(state)) {
const slice = [...state as unknown[]]
slice[index] = path.length === 0 ? value : set(slice[index], path, value)
return slice
}
const slice = { ...state as Record<string, unknown> }
slice[key] = path.length === 0 ? value : set(slice[key], path, value)
return slice
}
const unsetInRecord = (record: Record<string, unknown>, prop: string): Record<string, unknown> => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [prop]: _, ...copy } = record
return copy
}
export function unset (state: unknown, rawOrPath: string|string[]): unknown {
if (!isRecordLike(state)) { if (!isRecordLike(state)) {
return state return state
} }
const path = typeof fieldOrPath === 'string' ? extractPath(fieldOrPath) : fieldOrPath const path = typeof rawOrPath === 'string' ? extractPath(rawOrPath) : rawOrPath
if (path.length === 0) { if (path.length === 0) {
return state return state
@ -70,23 +99,23 @@ export function unset (state: unknown, fieldOrPath: string|string[]): unknown {
const index = extractIntOrNaN(key) const index = extractIntOrNaN(key)
if (!isNaN(index) && Array.isArray(state) && index >= 0 && index < state.length) { if (!isNaN(index) && Array.isArray(state) && index >= 0 && index < state.length) {
const values = (state as unknown[]).slice() const slice = [...state as unknown[]]
if (path.length === 0) { if (path.length === 0) {
values.splice(index, 1) slice.splice(index, 1)
} else { } else {
values[index] = unset(values[index], path) slice[index] = unset(slice[index], path)
} }
return values return slice
} }
if (has(state as Record<string, unknown>, key)) { if (has(state as Record<string, unknown>, key)) {
const values = state as Record<string, unknown> const slice = { ...state as Record<string, unknown> }
return path.length === 0 return path.length === 0
? unsetInRecord(values, key) ? unsetInRecord(slice, key)
: { ...values, [key]: unset(values[key], path) } : { ...slice, [key]: unset(slice[key], path) }
} }
return state return state

View File

@ -1,70 +1,7 @@
export { default as clone } from './clone' export { default as clone } from './clone'
export { default as has } from './has' export { default as has } from './has'
export { default as merge } from './merge' export { default as merge } from './merge'
export { get, unset } from './access' export { get, set, unset } from './access'
export { default as regexForFormat } from './regexForFormat' export { default as regexForFormat } from './regexForFormat'
export { default as shallowEquals } from './shallowEquals' export { default as shallowEquals } from './shallowEquals'
export { default as snakeToCamel } from './snakeToCamel' export { default as snakeToCamel } from './snakeToCamel'
export function getNested (obj: Record<string, any>, field: string): any {
const fieldParts = field.split('.')
let result: Record<string, any> = obj
for (const key in fieldParts) {
const matches = fieldParts[key].match(/(.+)\[(\d+)\]$/)
if (result === undefined) {
return null
}
if (matches) {
result = result[matches[1]]
if (result === undefined) {
return null
}
result = result[matches[2]]
} else {
result = result[fieldParts[key]]
}
}
return result
}
export function setNested (obj: Record<string, any>, field: string, value: any): void {
const fieldParts = field.split('.')
let subProxy: Record<string, any> = obj
for (let i = 0; i < fieldParts.length; i++) {
const fieldPart = fieldParts[i]
const matches = fieldPart.match(/(.+)\[(\d+)\]$/)
if (subProxy === undefined) {
break
}
if (matches) {
if (subProxy[matches[1]] === undefined) {
subProxy[matches[1]] = []
}
subProxy = subProxy[matches[1]]
if (i === fieldParts.length - 1) {
subProxy[matches[2]] = value
break
} else {
subProxy = subProxy[matches[2]]
}
} else {
if (i === fieldParts.length - 1) {
subProxy[fieldPart] = value
break
} else {
// eslint-disable-next-line max-depth
if (subProxy[fieldPart] === undefined) {
subProxy[fieldPart] = {}
}
subProxy = subProxy[fieldPart]
}
}
}
}

View File

@ -1,6 +1,30 @@
<template> <template>
<FormularioForm v-model="values"> <FormularioForm v-model="values">
<h1>Address list</h1> <h1>Delivery</h1>
<h3>Customer</h3>
<FormularioFieldGroup
name="customer"
class="row mx-n2"
>
<FormularioField
v-slot="{ context }"
name="name"
class="col col-auto px-2 mb-3"
>
<label for="customer-name">Name</label>
<input
id="customer-name"
v-model="context.model"
class="field form-control"
type="text"
@blur="context.runValidation"
>
</FormularioField>
</FormularioFieldGroup>
<h3>Address list</h3>
<FormularioField <FormularioField
v-slot="addressList" v-slot="addressList"
@ -19,7 +43,7 @@
name="street" name="street"
validation="required" validation="required"
> >
<label for="address-street">Street <span class="text-danger">*</span></label> <label for="address-street">Street</label>
<input <input
id="address-street" id="address-street"
v-model="context.model" v-model="context.model"
@ -43,7 +67,7 @@
name="building" name="building"
validation="^required|alphanumeric" validation="^required|alphanumeric"
> >
<label for="address-building">Building <span class="text-danger">*</span></label> <label for="address-building">Building</label>
<input <input
id="address-building" id="address-building"
v-model="context.model" v-model="context.model"

View File

@ -3,7 +3,7 @@ import './bootstrap.scss'
import { storiesOf } from '@storybook/vue' import { storiesOf } from '@storybook/vue'
import Vue from 'vue' import Vue from 'vue'
import VueFormulario from '../../dist/formulario.esm' import VueFormulario from '@/index.ts'
import ExampleAddressList from './ExampleAddressList.tale' import ExampleAddressList from './ExampleAddressList.tale'

View File

@ -165,6 +165,7 @@ describe('FormularioForm', () => {
const state = { const state = {
address: { address: {
street: null, street: null,
building: null,
}, },
} }
@ -175,7 +176,7 @@ describe('FormularioForm', () => {
args: [], args: [],
context: { context: {
name: 'address.street', name: 'address.street',
value: null, value: '',
formValues: state, formValues: state,
}, },
}], }],
@ -211,6 +212,7 @@ describe('FormularioForm', () => {
const state = { const state = {
address: { address: {
street: null, street: null,
building: null,
}, },
} }
@ -221,7 +223,7 @@ describe('FormularioForm', () => {
args: [], args: [],
context: { context: {
name: 'address.street', name: 'address.street',
value: null, value: '',
formValues: state, formValues: state,
}, },
}], }],

View File

@ -1,4 +1,4 @@
import { get, unset } from '@/utils/access' import { get, set, unset } from '@/utils/access'
class Sample { class Sample {
constructor() { constructor() {
@ -25,8 +25,35 @@ describe('access', () => {
[[{ c: 1 }, 2, 3], '[0].c', 1], [[{ c: 1 }, 2, 3], '[0].c', 1],
[[{ c: 2 }, 2, 3], '[0].c', 2], [[{ c: 2 }, 2, 3], '[0].c', 2],
[new Sample(), 'fieldA', 'fieldA'], [new Sample(), 'fieldA', 'fieldA'],
])('gets by path', (record, path, expected) => { ])('gets by path', (state, path, expected) => {
expect(get(record, path)).toEqual(expected) expect(get(state, path)).toEqual(expected)
})
})
describe('set', () => {
test.each([
[{}, 'a', 1, { a: 1 }],
[null, 'a', 1, { a: 1 }],
['', 'a', 1, { a: 1 }],
['lorem', 'a', 1, { a: 1 }],
[true, 'a', 1, { a: 1 }],
[{}, 'a.b', 1, { a: { b: 1 } }],
[{ a: { b: null } }, 'a.b', 1, { a: { b: 1 } }],
[{ a: false }, 'a.b', 1, { a: { b: 1 } }],
[{}, 'a[0]', 1, { a: [1] }],
[{ a: false }, 'a[0]', 1, { a: [1] }],
[{}, 'a[0].b', 1, { a: [{ b: 1 }] }],
[{ a: false }, 'a[0].b', 1, { a: [{ b: 1 }] }],
[{}, 'a[0].b.c', 1, { a: [{ b: { c: 1 } }] }],
[{}, 'a[0].b[0].c', 1, { a: [{ b: [{ c: 1 }] }] }],
[{ a: false }, 'a[0].b[0].c', 1, { a: [{ b: [{ c: 1 }] }] }],
[{ a: [{ b: false }] }, 'a[0].b[0].c', 1, { a: [{ b: [{ c: 1 }] }] }],
[{ a: { b: false } }, 'a[0].b[0].c', 1, { a: { 0: { b: [{ c: 1 }] }, b: false } }],
])('sets by path', (state, path, value, expected) => {
const processed = set(state, path, value)
expect(processed).toEqual(expected)
expect(processed === state).toBeFalsy()
}) })
}) })
@ -43,11 +70,11 @@ describe('access', () => {
[{ a: { b: [{ c: 1 }, 2, 3] } }, 'a.b[0].c', { a: { b: [{}, 2, 3] } }], [{ a: { b: [{ c: 1 }, 2, 3] } }, 'a.b[0].c', { a: { b: [{}, 2, 3] } }],
[{ a: { b: [{ c: 1 }, 2, 3] } }, 'a.b[1].c', { a: { b: [{ c: 1 }, 2, 3] } }], [{ a: { b: [{ c: 1 }, 2, 3] } }, 'a.b[1].c', { a: { b: [{ c: 1 }, 2, 3] } }],
[[{ c: 1 }, 2, 3], '[0].c', [{}, 2, 3]], [[{ c: 1 }, 2, 3], '[0].c', [{}, 2, 3]],
])('unsets by path', (record, path, expected) => { ])('unsets by path', (state, path, expected) => {
const processed = unset(record, path) const processed = unset(state, path)
expect(processed).toEqual(expected) expect(processed).toEqual(expected)
expect(processed === record).toBeFalsy() expect(processed === state).toBeFalsy()
}) })
test.each` test.each`