refactor: State management logic & tests
This commit is contained in:
parent
9868d99c19
commit
a1476d9986
@ -26,7 +26,7 @@ export default class Formulario {
|
||||
|
||||
private readonly registry: Map<string, FormularioFormInterface>
|
||||
|
||||
constructor (options?: FormularioOptions) {
|
||||
public constructor (options?: FormularioOptions) {
|
||||
this.registry = new Map()
|
||||
|
||||
this.validationRules = validationRules
|
||||
@ -38,7 +38,7 @@ export default class Formulario {
|
||||
/**
|
||||
* 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') {
|
||||
this.validationRules = merge(this.validationRules, extendWith.validationRules || {})
|
||||
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})`)
|
||||
}
|
||||
|
||||
runValidation (id: string): Promise<ViolationsRecord> {
|
||||
public runValidation (id: string): Promise<ViolationsRecord> {
|
||||
if (!this.registry.has(id)) {
|
||||
throw new Error(`[Formulario]: Formulario.runValidation(): no forms with id "${id}"`)
|
||||
}
|
||||
@ -57,7 +57,7 @@ export default class Formulario {
|
||||
return form.runValidation()
|
||||
}
|
||||
|
||||
resetValidation (id: string): void {
|
||||
public resetValidation (id: string): void {
|
||||
if (!this.registry.has(id)) {
|
||||
return
|
||||
}
|
||||
@ -71,7 +71,7 @@ export default class Formulario {
|
||||
* Used by forms instances to add themselves into a registry
|
||||
* @internal
|
||||
*/
|
||||
register (id: string, form: FormularioFormInterface): void {
|
||||
public register (id: string, form: FormularioFormInterface): void {
|
||||
if (this.registry.has(id)) {
|
||||
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
|
||||
* @internal
|
||||
*/
|
||||
unregister (id: string): void {
|
||||
public unregister (id: string): void {
|
||||
if (this.registry.has(id)) {
|
||||
this.registry.delete(id)
|
||||
}
|
||||
@ -93,7 +93,7 @@ export default class Formulario {
|
||||
* Get validation rules by merging any passed in with global rules.
|
||||
* @internal
|
||||
*/
|
||||
getRules (extendWith: Record<string, ValidationRuleFn> = {}): Record<string, ValidationRuleFn> {
|
||||
public getRules (extendWith: Record<string, ValidationRuleFn> = {}): Record<string, ValidationRuleFn> {
|
||||
return merge(this.validationRules, extendWith)
|
||||
}
|
||||
|
||||
@ -101,7 +101,7 @@ export default class Formulario {
|
||||
* Get validation messages by merging any passed in with global messages.
|
||||
* @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 messages: Record<string, ValidationMessageFn> = {}
|
||||
|
||||
|
@ -15,11 +15,12 @@ import {
|
||||
} from 'vue-property-decorator'
|
||||
import {
|
||||
clone,
|
||||
getNested,
|
||||
get,
|
||||
has,
|
||||
merge,
|
||||
setNested,
|
||||
set,
|
||||
shallowEquals,
|
||||
unset,
|
||||
} from '@/utils'
|
||||
|
||||
import PathRegistry from '@/PathRegistry'
|
||||
@ -86,10 +87,15 @@ export default class FormularioForm extends Vue {
|
||||
private register (path: string, field: FormularioFieldInterface): void {
|
||||
this.registry.add(path, field)
|
||||
|
||||
const value = getNested(this.modelCopy, path)
|
||||
const value = get(this.modelCopy, path)
|
||||
|
||||
if (!field.hasModel && this.modelIsDefined && value !== undefined) {
|
||||
field.model = value
|
||||
if (!field.hasModel && this.modelIsDefined) {
|
||||
if (value !== undefined) {
|
||||
field.model = value
|
||||
} else {
|
||||
this.setFieldValue(path, null)
|
||||
this.emitInput()
|
||||
}
|
||||
} else if (field.hasModel && !shallowEquals(field.proxy, value)) {
|
||||
this.setFieldValue(path, field.proxy)
|
||||
this.emitInput()
|
||||
@ -116,13 +122,11 @@ export default class FormularioForm extends Vue {
|
||||
}
|
||||
|
||||
@Provide('__FormularioForm_set')
|
||||
private setFieldValue (field: string, value: unknown): void {
|
||||
private setFieldValue (path: string, value: unknown): void {
|
||||
if (value === undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [field]: value, ...proxy } = this.proxy
|
||||
this.proxy = proxy
|
||||
this.proxy = unset(this.proxy, path) as Record<string, unknown>
|
||||
} 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) => {
|
||||
const oldValue = getNested(this.proxy, path)
|
||||
const newValue = getNested(state, path)
|
||||
const oldValue = get(this.proxy, path, null)
|
||||
const newValue = get(state, path, null)
|
||||
|
||||
if (!shallowEquals(newValue, oldValue)) {
|
||||
this.setFieldValue(path, newValue)
|
||||
|
@ -7,6 +7,7 @@ import FormularioFieldGroup from '@/FormularioFieldGroup.vue'
|
||||
import FormularioForm from '@/FormularioForm.vue'
|
||||
|
||||
export default {
|
||||
Formulario,
|
||||
install (Vue: VueConstructor, options?: FormularioOptions): void {
|
||||
Vue.component('FormularioField', FormularioField)
|
||||
Vue.component('FormularioFieldGroup', FormularioFieldGroup)
|
||||
|
@ -7,10 +7,10 @@ const extractIntOrNaN = (value: string): number => {
|
||||
return numeric.toString() === value ? numeric : NaN
|
||||
}
|
||||
|
||||
const extractPath = (field: string): string[] => {
|
||||
const extractPath = (raw: string): string[] => {
|
||||
const path = [] as string[]
|
||||
|
||||
field.split('.').forEach(key => {
|
||||
raw.split('.').forEach(key => {
|
||||
if (/(.*)\[(\d+)]$/.test(key)) {
|
||||
path.push(...key.substr(0, key.length - 1).split('[').filter(k => k.length))
|
||||
} else {
|
||||
@ -21,18 +21,11 @@ const extractPath = (field: string): string[] => {
|
||||
return path
|
||||
}
|
||||
|
||||
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 get (state: unknown, fieldOrPath: string|string[]): unknown {
|
||||
const path = typeof fieldOrPath === 'string' ? extractPath(fieldOrPath) : fieldOrPath
|
||||
export function get (state: unknown, rawOrPath: string|string[], fallback: unknown = undefined): unknown {
|
||||
const path = typeof rawOrPath === 'string' ? extractPath(rawOrPath) : rawOrPath
|
||||
|
||||
if (isScalar(state) || path.length === 0) {
|
||||
return undefined
|
||||
return fallback
|
||||
}
|
||||
|
||||
const key = path.shift() as string
|
||||
@ -40,7 +33,7 @@ export function get (state: unknown, fieldOrPath: string|string[]): unknown {
|
||||
|
||||
if (!isNaN(index)) {
|
||||
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
|
||||
@ -49,18 +42,54 @@ export function get (state: unknown, fieldOrPath: string|string[]): unknown {
|
||||
if (has(state as Record<string, unknown>, key)) {
|
||||
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
|
||||
}
|
||||
|
||||
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)) {
|
||||
return state
|
||||
}
|
||||
|
||||
const path = typeof fieldOrPath === 'string' ? extractPath(fieldOrPath) : fieldOrPath
|
||||
const path = typeof rawOrPath === 'string' ? extractPath(rawOrPath) : rawOrPath
|
||||
|
||||
if (path.length === 0) {
|
||||
return state
|
||||
@ -70,23 +99,23 @@ export function unset (state: unknown, fieldOrPath: string|string[]): unknown {
|
||||
const index = extractIntOrNaN(key)
|
||||
|
||||
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) {
|
||||
values.splice(index, 1)
|
||||
slice.splice(index, 1)
|
||||
} 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)) {
|
||||
const values = state as Record<string, unknown>
|
||||
const slice = { ...state as Record<string, unknown> }
|
||||
|
||||
return path.length === 0
|
||||
? unsetInRecord(values, key)
|
||||
: { ...values, [key]: unset(values[key], path) }
|
||||
? unsetInRecord(slice, key)
|
||||
: { ...slice, [key]: unset(slice[key], path) }
|
||||
}
|
||||
|
||||
return state
|
||||
|
@ -1,70 +1,7 @@
|
||||
export { default as clone } from './clone'
|
||||
export { default as has } from './has'
|
||||
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 shallowEquals } from './shallowEquals'
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,30 @@
|
||||
<template>
|
||||
<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
|
||||
v-slot="addressList"
|
||||
@ -19,7 +43,7 @@
|
||||
name="street"
|
||||
validation="required"
|
||||
>
|
||||
<label for="address-street">Street <span class="text-danger">*</span></label>
|
||||
<label for="address-street">Street</label>
|
||||
<input
|
||||
id="address-street"
|
||||
v-model="context.model"
|
||||
@ -43,7 +67,7 @@
|
||||
name="building"
|
||||
validation="^required|alphanumeric"
|
||||
>
|
||||
<label for="address-building">Building <span class="text-danger">*</span></label>
|
||||
<label for="address-building">Building</label>
|
||||
<input
|
||||
id="address-building"
|
||||
v-model="context.model"
|
||||
|
@ -3,7 +3,7 @@ import './bootstrap.scss'
|
||||
import { storiesOf } from '@storybook/vue'
|
||||
|
||||
import Vue from 'vue'
|
||||
import VueFormulario from '../../dist/formulario.esm'
|
||||
import VueFormulario from '@/index.ts'
|
||||
|
||||
import ExampleAddressList from './ExampleAddressList.tale'
|
||||
|
||||
|
@ -165,6 +165,7 @@ describe('FormularioForm', () => {
|
||||
const state = {
|
||||
address: {
|
||||
street: null,
|
||||
building: null,
|
||||
},
|
||||
}
|
||||
|
||||
@ -175,7 +176,7 @@ describe('FormularioForm', () => {
|
||||
args: [],
|
||||
context: {
|
||||
name: 'address.street',
|
||||
value: null,
|
||||
value: '',
|
||||
formValues: state,
|
||||
},
|
||||
}],
|
||||
@ -211,6 +212,7 @@ describe('FormularioForm', () => {
|
||||
const state = {
|
||||
address: {
|
||||
street: null,
|
||||
building: null,
|
||||
},
|
||||
}
|
||||
|
||||
@ -221,7 +223,7 @@ describe('FormularioForm', () => {
|
||||
args: [],
|
||||
context: {
|
||||
name: 'address.street',
|
||||
value: null,
|
||||
value: '',
|
||||
formValues: state,
|
||||
},
|
||||
}],
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { get, unset } from '@/utils/access'
|
||||
import { get, set, unset } from '@/utils/access'
|
||||
|
||||
class Sample {
|
||||
constructor() {
|
||||
@ -25,8 +25,35 @@ describe('access', () => {
|
||||
[[{ c: 1 }, 2, 3], '[0].c', 1],
|
||||
[[{ c: 2 }, 2, 3], '[0].c', 2],
|
||||
[new Sample(), 'fieldA', 'fieldA'],
|
||||
])('gets by path', (record, path, expected) => {
|
||||
expect(get(record, path)).toEqual(expected)
|
||||
])('gets by path', (state, path, 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[1].c', { a: { b: [{ c: 1 }, 2, 3] } }],
|
||||
[[{ c: 1 }, 2, 3], '[0].c', [{}, 2, 3]],
|
||||
])('unsets by path', (record, path, expected) => {
|
||||
const processed = unset(record, path)
|
||||
])('unsets by path', (state, path, expected) => {
|
||||
const processed = unset(state, path)
|
||||
|
||||
expect(processed).toEqual(expected)
|
||||
expect(processed === record).toBeFalsy()
|
||||
expect(processed === state).toBeFalsy()
|
||||
})
|
||||
|
||||
test.each`
|
||||
|
Loading…
Reference in New Issue
Block a user