refactor: Deep cloning of state, deep equal checker
This commit is contained in:
parent
7c2a9e8110
commit
b37040d2d3
@ -7,10 +7,10 @@ import {
|
||||
ValidationRuleFn,
|
||||
ValidationMessageFn,
|
||||
ValidationMessageI18NFn,
|
||||
ViolationsRecord,
|
||||
Violation,
|
||||
} from '@/validation/validator'
|
||||
|
||||
import { FormularioFormInterface } from '@/types'
|
||||
import { FormularioForm } from '@/types'
|
||||
|
||||
export interface FormularioOptions {
|
||||
validationRules?: Record<string, ValidationRuleFn>;
|
||||
@ -24,7 +24,7 @@ export default class Formulario {
|
||||
public validationRules: Record<string, ValidationRuleFn> = {}
|
||||
public validationMessages: Record<string, ValidationMessageI18NFn|string> = {}
|
||||
|
||||
private readonly registry: Map<string, FormularioFormInterface>
|
||||
private readonly registry: Map<string, FormularioForm>
|
||||
|
||||
public constructor (options?: FormularioOptions) {
|
||||
this.registry = new Map()
|
||||
@ -47,12 +47,12 @@ export default class Formulario {
|
||||
throw new Error(`[Formulario]: Formulario.extend(): should be passed an object (was ${typeof extendWith})`)
|
||||
}
|
||||
|
||||
public runValidation (id: string): Promise<ViolationsRecord> {
|
||||
public runValidation (id: string): Promise<Record<string, Violation[]>> {
|
||||
if (!this.registry.has(id)) {
|
||||
throw new Error(`[Formulario]: Formulario.runValidation(): no forms with id "${id}"`)
|
||||
}
|
||||
|
||||
const form = this.registry.get(id) as FormularioFormInterface
|
||||
const form = this.registry.get(id) as FormularioForm
|
||||
|
||||
return form.runValidation()
|
||||
}
|
||||
@ -62,7 +62,7 @@ export default class Formulario {
|
||||
return
|
||||
}
|
||||
|
||||
const form = this.registry.get(id) as FormularioFormInterface
|
||||
const form = this.registry.get(id) as FormularioForm
|
||||
|
||||
form.resetValidation()
|
||||
}
|
||||
@ -71,7 +71,7 @@ export default class Formulario {
|
||||
* Used by forms instances to add themselves into a registry
|
||||
* @internal
|
||||
*/
|
||||
public register (id: string, form: FormularioFormInterface): void {
|
||||
public register (id: string, form: FormularioForm): void {
|
||||
if (this.registry.has(id)) {
|
||||
throw new Error(`[Formulario]: Formulario.register(): id "${id}" is already in use`)
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
Prop,
|
||||
Watch,
|
||||
} from 'vue-property-decorator'
|
||||
import { has, shallowEquals, snakeToCamel } from './utils'
|
||||
import { deepEquals, has, snakeToCamel } from './utils'
|
||||
import {
|
||||
processConstraints,
|
||||
validate,
|
||||
@ -87,35 +87,18 @@ export default class FormularioField extends Vue {
|
||||
return has(this.$options.propsData || {}, 'value')
|
||||
}
|
||||
|
||||
public get model (): unknown {
|
||||
return this.modelGetConverter(this.hasModel ? this.value : this.proxy)
|
||||
}
|
||||
|
||||
public set model (value: unknown) {
|
||||
value = this.modelSetConverter(value, this.proxy)
|
||||
|
||||
if (!shallowEquals(value, this.proxy)) {
|
||||
this.proxy = value
|
||||
this.$emit('input', value)
|
||||
|
||||
if (typeof this.__FormularioForm_set === 'function') {
|
||||
this.__FormularioForm_set(this.fullPath, value)
|
||||
this.__FormularioForm_emitInput()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private get context (): FormularioFieldContext<unknown> {
|
||||
return Object.defineProperty({
|
||||
name: this.fullPath,
|
||||
path: this.fullPath,
|
||||
runValidation: this.runValidation.bind(this),
|
||||
violations: this.violations,
|
||||
errors: this.localErrors,
|
||||
allErrors: [...this.localErrors, ...this.violations.map(v => v.message)],
|
||||
}, 'model', {
|
||||
get: () => this.model,
|
||||
set: (value: unknown) => {
|
||||
this.model = value
|
||||
get: () => this.modelGetConverter(this.proxy),
|
||||
set: (value: unknown): void => {
|
||||
this.syncProxy(this.modelSetConverter(value, this.proxy))
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -137,18 +120,12 @@ export default class FormularioField extends Vue {
|
||||
}
|
||||
|
||||
@Watch('value')
|
||||
private onValueChange (newValue: unknown, oldValue: unknown): void {
|
||||
if (this.hasModel && !shallowEquals(newValue, oldValue)) {
|
||||
this.model = newValue
|
||||
}
|
||||
private onValueChange (): void {
|
||||
this.syncProxy(this.value)
|
||||
}
|
||||
|
||||
@Watch('proxy')
|
||||
private onProxyChange (newValue: unknown, oldValue: unknown): void {
|
||||
if (!this.hasModel && !shallowEquals(newValue, oldValue)) {
|
||||
this.model = newValue
|
||||
}
|
||||
|
||||
private onProxyChange (): void {
|
||||
if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
|
||||
this.runValidation()
|
||||
} else {
|
||||
@ -160,10 +137,6 @@ export default class FormularioField extends Vue {
|
||||
* @internal
|
||||
*/
|
||||
public created (): void {
|
||||
if (!shallowEquals(this.model, this.proxy)) {
|
||||
this.model = this.proxy
|
||||
}
|
||||
|
||||
if (typeof this.__FormularioForm_register === 'function') {
|
||||
this.__FormularioForm_register(this.fullPath, this)
|
||||
}
|
||||
@ -182,13 +155,22 @@ export default class FormularioField extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
private syncProxy (value: unknown): void {
|
||||
if (!deepEquals(value, this.proxy)) {
|
||||
this.proxy = value
|
||||
this.$emit('input', value)
|
||||
|
||||
if (typeof this.__FormularioForm_set === 'function') {
|
||||
this.__FormularioForm_set(this.fullPath, value)
|
||||
this.__FormularioForm_emitInput()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public runValidation (): Promise<Violation[]> {
|
||||
this.validationRun = this.validate().then(violations => {
|
||||
if (!shallowEquals(this.violations, violations)) {
|
||||
this.emitValidation(this.fullPath, violations)
|
||||
}
|
||||
|
||||
this.violations = violations
|
||||
this.emitValidation(this.fullPath, violations)
|
||||
|
||||
return this.violations
|
||||
})
|
||||
@ -202,8 +184,8 @@ export default class FormularioField extends Vue {
|
||||
this.$formulario.getRules(this.normalizedValidationRules),
|
||||
this.$formulario.getMessages(this, this.normalizedValidationMessages),
|
||||
), {
|
||||
value: this.context.model,
|
||||
name: this.context.name,
|
||||
value: this.proxy,
|
||||
name: this.fullPath,
|
||||
formValues: this.__FormularioForm_getState(),
|
||||
})
|
||||
}
|
||||
|
@ -22,17 +22,17 @@ export default class FormularioFieldGroup extends Vue {
|
||||
|
||||
@Provide('__Formulario_path')
|
||||
get fullPath (): string {
|
||||
const name = `${this.name}`
|
||||
const path = `${this.name}`
|
||||
|
||||
if (parseInt(name).toString() === name) {
|
||||
return `${this.__Formulario_path}[${name}]`
|
||||
if (parseInt(path).toString() === path) {
|
||||
return `${this.__Formulario_path}[${path}]`
|
||||
}
|
||||
|
||||
if (this.__Formulario_path === '') {
|
||||
return name
|
||||
return path
|
||||
}
|
||||
|
||||
return `${this.__Formulario_path}.${name}`
|
||||
return `${this.__Formulario_path}.${path}`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -14,67 +14,51 @@ import {
|
||||
Watch,
|
||||
} from 'vue-property-decorator'
|
||||
import {
|
||||
id,
|
||||
clone,
|
||||
deepEquals,
|
||||
get,
|
||||
has,
|
||||
merge,
|
||||
set,
|
||||
shallowEquals,
|
||||
unset,
|
||||
} from '@/utils'
|
||||
|
||||
import PathRegistry from '@/PathRegistry'
|
||||
|
||||
import { FormularioFieldInterface } from '@/types'
|
||||
import {
|
||||
Violation,
|
||||
ViolationsRecord,
|
||||
} from '@/validation/validator'
|
||||
|
||||
type ErrorsRecord = Record<string, string[]>
|
||||
import { FormularioField } from '@/types'
|
||||
import { Violation } from '@/validation/validator'
|
||||
|
||||
type ValidationEventPayload = {
|
||||
name: string;
|
||||
violations: Violation[];
|
||||
}
|
||||
|
||||
let counter = 0
|
||||
const update = (state: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> => {
|
||||
if (value === undefined) {
|
||||
return unset(state, path) as Record<string, unknown>
|
||||
}
|
||||
|
||||
return set(state, path, value) as Record<string, unknown>
|
||||
}
|
||||
|
||||
@Component({ name: 'FormularioForm' })
|
||||
export default class FormularioForm extends Vue {
|
||||
@Model('input', { default: () => ({}) })
|
||||
public readonly state!: Record<string, unknown>
|
||||
|
||||
@Prop({ default: () => `formulario-form-${++counter}` })
|
||||
@Prop({ default: () => id('formulario-form') })
|
||||
public readonly id!: string
|
||||
|
||||
// Describes validation errors of whole form
|
||||
@Prop({ default: () => ({}) }) readonly fieldsErrors!: ErrorsRecord
|
||||
@Prop({ default: () => ({}) }) readonly fieldsErrors!: Record<string, string[]>
|
||||
// Only used on FormularioForm default slot
|
||||
@Prop({ default: () => ([]) }) readonly formErrors!: string[]
|
||||
|
||||
private proxy: Record<string, unknown> = {}
|
||||
private registry: PathRegistry<FormularioFieldInterface> = new PathRegistry()
|
||||
private registry: Map<string, FormularioField> = new Map()
|
||||
// Local error messages are temporal, they wiped each resetValidation call
|
||||
private localFieldsErrors: ErrorsRecord = {}
|
||||
private localFieldsErrors: Record<string, string[]> = {}
|
||||
private localFormErrors: string[] = []
|
||||
|
||||
private get hasModel (): boolean {
|
||||
return has(this.$options.propsData || {}, 'state')
|
||||
}
|
||||
|
||||
private get modelIsDefined (): boolean {
|
||||
return this.state && typeof this.state === 'object'
|
||||
}
|
||||
|
||||
private get modelCopy (): Record<string, unknown> {
|
||||
if (this.hasModel && typeof this.state === 'object') {
|
||||
return { ...this.state } // @todo - use a deep clone to detach reference types
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
private get fieldsErrorsComputed (): Record<string, string[]> {
|
||||
return merge(this.fieldsErrors || {}, this.localFieldsErrors)
|
||||
}
|
||||
@ -84,19 +68,21 @@ export default class FormularioForm extends Vue {
|
||||
}
|
||||
|
||||
@Provide('__FormularioForm_register')
|
||||
private register (path: string, field: FormularioFieldInterface): void {
|
||||
this.registry.add(path, field)
|
||||
private register (path: string, field: FormularioField): void {
|
||||
if (!this.registry.has(path)) {
|
||||
this.registry.set(path, field)
|
||||
}
|
||||
|
||||
const value = get(this.modelCopy, path)
|
||||
const value = get(this.proxy, path)
|
||||
|
||||
if (!field.hasModel && this.modelIsDefined) {
|
||||
if (!field.hasModel) {
|
||||
if (value !== undefined) {
|
||||
field.model = value
|
||||
field.proxy = value
|
||||
} else {
|
||||
this.setFieldValue(path, null)
|
||||
this.emitInput()
|
||||
}
|
||||
} else if (field.hasModel && !shallowEquals(field.proxy, value)) {
|
||||
} else if (!deepEquals(field.proxy, value)) {
|
||||
this.setFieldValue(path, field.proxy)
|
||||
this.emitInput()
|
||||
}
|
||||
@ -109,10 +95,9 @@ export default class FormularioForm extends Vue {
|
||||
@Provide('__FormularioForm_unregister')
|
||||
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
|
||||
this.registry.delete(path)
|
||||
this.proxy = unset(this.proxy, path) as Record<string, unknown>
|
||||
this.emitInput()
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,16 +108,12 @@ export default class FormularioForm extends Vue {
|
||||
|
||||
@Provide('__FormularioForm_set')
|
||||
private setFieldValue (path: string, value: unknown): void {
|
||||
if (value === undefined) {
|
||||
this.proxy = unset(this.proxy, path) as Record<string, unknown>
|
||||
} else {
|
||||
this.proxy = set(this.proxy, path, value) as Record<string, unknown>
|
||||
}
|
||||
this.proxy = update(this.proxy, path, value)
|
||||
}
|
||||
|
||||
@Provide('__FormularioForm_emitInput')
|
||||
private emitInput (): void {
|
||||
this.$emit('input', { ...this.proxy })
|
||||
this.$emit('input', clone(this.proxy))
|
||||
}
|
||||
|
||||
@Provide('__FormularioForm_emitValidation')
|
||||
@ -141,9 +122,29 @@ export default class FormularioForm extends Vue {
|
||||
}
|
||||
|
||||
@Watch('state', { deep: true })
|
||||
private onStateChange (state: Record<string, unknown>): void {
|
||||
if (this.hasModel && state && typeof state === 'object') {
|
||||
this.loadState(state)
|
||||
private onStateChange (newState: Record<string, unknown>): void {
|
||||
const newProxy = clone(newState)
|
||||
const oldProxy = this.proxy
|
||||
|
||||
let proxyHasChanges = false
|
||||
|
||||
this.registry.forEach((field, path) => {
|
||||
const newValue = get(newState, path, null)
|
||||
const oldValue = get(oldProxy, path, null)
|
||||
|
||||
field.proxy = newValue
|
||||
|
||||
if (!deepEquals(newValue, oldValue)) {
|
||||
field.$emit('input', newValue)
|
||||
update(newProxy, path, newValue)
|
||||
proxyHasChanges = true
|
||||
}
|
||||
})
|
||||
|
||||
this.proxy = newProxy
|
||||
|
||||
if (proxyHasChanges) {
|
||||
this.emitInput()
|
||||
}
|
||||
}
|
||||
|
||||
@ -155,18 +156,22 @@ export default class FormularioForm extends Vue {
|
||||
}
|
||||
|
||||
public created (): void {
|
||||
this.syncProxy()
|
||||
this.$formulario.register(this.id, this)
|
||||
if (typeof this.state === 'object') {
|
||||
this.proxy = clone(this.state)
|
||||
}
|
||||
}
|
||||
|
||||
public beforeDestroy (): void {
|
||||
this.$formulario.unregister(this.id)
|
||||
}
|
||||
|
||||
public runValidation (): Promise<ViolationsRecord> {
|
||||
const violations: ViolationsRecord = {}
|
||||
const runs = this.registry.map((field: FormularioFieldInterface, path: string) => {
|
||||
return field.runValidation().then(v => { violations[path] = v })
|
||||
public runValidation (): Promise<Record<string, Violation[]>> {
|
||||
const runs: Promise<void>[] = []
|
||||
const violations: Record<string, Violation[]> = {}
|
||||
|
||||
this.registry.forEach((field, path) => {
|
||||
runs.push(field.runValidation().then(v => { violations[path] = v }))
|
||||
})
|
||||
|
||||
return Promise.all(runs).then(() => violations)
|
||||
@ -178,7 +183,10 @@ export default class FormularioForm extends Vue {
|
||||
})
|
||||
}
|
||||
|
||||
public setErrors ({ fieldsErrors, formErrors }: { fieldsErrors?: ErrorsRecord; formErrors?: string[] }): void {
|
||||
public setErrors ({ fieldsErrors, formErrors }: {
|
||||
fieldsErrors?: Record<string, string[]>;
|
||||
formErrors?: string[];
|
||||
}): void {
|
||||
this.localFieldsErrors = fieldsErrors || {}
|
||||
this.localFormErrors = formErrors || []
|
||||
}
|
||||
@ -186,63 +194,21 @@ export default class FormularioForm extends Vue {
|
||||
public resetValidation (): void {
|
||||
this.localFieldsErrors = {}
|
||||
this.localFormErrors = []
|
||||
this.registry.forEach((field: FormularioFieldInterface) => {
|
||||
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)
|
||||
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),
|
||||
]))
|
||||
|
||||
let proxyHasChanges = false
|
||||
|
||||
paths.forEach(path => {
|
||||
if (!this.registry.hasSubset(path)) {
|
||||
return
|
||||
if (!hasErrors) {
|
||||
this.$emit('submit', clone(this.proxy))
|
||||
} else {
|
||||
this.$emit('error', violations)
|
||||
}
|
||||
|
||||
this.registry.getSubset(path).forEach((field, path) => {
|
||||
const oldValue = get(this.proxy, path, null)
|
||||
const newValue = get(state, path, null)
|
||||
|
||||
if (!shallowEquals(newValue, oldValue)) {
|
||||
this.setFieldValue(path, newValue)
|
||||
proxyHasChanges = true
|
||||
}
|
||||
|
||||
if (!shallowEquals(newValue, field.proxy)) {
|
||||
field.model = newValue
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.syncProxy()
|
||||
|
||||
if (proxyHasChanges) {
|
||||
this.$emit('input', { ...this.proxy })
|
||||
}
|
||||
}
|
||||
|
||||
private syncProxy (): void {
|
||||
if (this.modelIsDefined) {
|
||||
this.proxy = this.modelCopy
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,71 +0,0 @@
|
||||
/**
|
||||
* @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()
|
||||
}
|
||||
|
||||
forEach (callback: (field: T, path: string) => void): void {
|
||||
this.registry.forEach((field, path) => {
|
||||
callback(field, path)
|
||||
})
|
||||
}
|
||||
|
||||
map<U> (mapper: (item: T, path: string) => U): U[] {
|
||||
return Array.from(this.registry.keys()).map(path => mapper(this.get(path) as T, path))
|
||||
}
|
||||
}
|
64
src/types.ts
64
src/types.ts
@ -1,13 +1,13 @@
|
||||
import { Violation, ViolationsRecord } from '@/validation/validator'
|
||||
import Vue from 'vue'
|
||||
import { Violation } from '@/validation/validator'
|
||||
|
||||
export interface FormularioFormInterface {
|
||||
runValidation(): Promise<ViolationsRecord>;
|
||||
export interface FormularioForm extends Vue {
|
||||
runValidation(): Promise<Record<string, Violation[]>>;
|
||||
resetValidation(): void;
|
||||
}
|
||||
|
||||
export interface FormularioFieldInterface {
|
||||
export interface FormularioField extends Vue {
|
||||
hasModel: boolean;
|
||||
model: unknown;
|
||||
proxy: unknown;
|
||||
setErrors(errors: string[]): void;
|
||||
runValidation(): Promise<Violation[]>;
|
||||
@ -33,10 +33,58 @@ export interface FormularioFieldModelSetConverter {
|
||||
|
||||
export type Empty = undefined | null
|
||||
|
||||
export type RecordKey = string | number
|
||||
export type RecordLike<T> = T[] | Record<RecordKey, T>
|
||||
export enum TYPE {
|
||||
ARRAY = 'ARRAY',
|
||||
BIGINT = 'BIGINT',
|
||||
BOOLEAN = 'BOOLEAN',
|
||||
DATE = 'DATE',
|
||||
FUNCTION = 'FUNCTION',
|
||||
NUMBER = 'NUMBER',
|
||||
RECORD = 'RECORD',
|
||||
STRING = 'STRING',
|
||||
SYMBOL = 'SYMBOL',
|
||||
UNDEFINED = 'UNDEFINED',
|
||||
NULL = 'NULL',
|
||||
}
|
||||
|
||||
export type Scalar = boolean | number | string | symbol | Empty
|
||||
export function typeOf (value: unknown): string {
|
||||
switch (typeof value) {
|
||||
case 'bigint':
|
||||
return TYPE.BIGINT
|
||||
case 'boolean':
|
||||
return TYPE.BOOLEAN
|
||||
case 'function':
|
||||
return TYPE.FUNCTION
|
||||
case 'number':
|
||||
return TYPE.NUMBER
|
||||
case 'string':
|
||||
return TYPE.STRING
|
||||
case 'symbol':
|
||||
return TYPE.SYMBOL
|
||||
case 'undefined':
|
||||
return TYPE.UNDEFINED
|
||||
case 'object':
|
||||
if (value === null) {
|
||||
return TYPE.NULL
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return TYPE.DATE
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return TYPE.ARRAY
|
||||
}
|
||||
|
||||
if (value.constructor.name === 'Object') {
|
||||
return TYPE.RECORD
|
||||
}
|
||||
|
||||
return 'InstanceOf<' + value.constructor.name + '>'
|
||||
}
|
||||
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
export function isRecordLike (value: unknown): boolean {
|
||||
return typeof value === 'object' && value !== null && ['Array', 'Object'].includes(value.constructor.name)
|
||||
|
@ -1,10 +1,4 @@
|
||||
import has from '@/utils/has'
|
||||
import {
|
||||
RecordLike,
|
||||
Scalar,
|
||||
isRecordLike,
|
||||
isScalar,
|
||||
} from '@/types'
|
||||
import { isRecordLike, isScalar } from '@/types'
|
||||
|
||||
const cloneInstance = <T>(original: T): T => {
|
||||
return Object.assign(Object.create(Object.getPrototypeOf(original)), original)
|
||||
@ -14,27 +8,27 @@ const cloneInstance = <T>(original: T): T => {
|
||||
* A simple (somewhat non-comprehensive) clone function, valid for our use
|
||||
* case of needing to unbind reactive watchers.
|
||||
*/
|
||||
export default function clone (value: unknown): unknown {
|
||||
export default function clone<T = unknown> (value: T): T {
|
||||
if (isScalar(value)) {
|
||||
return value as Scalar
|
||||
return value
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return new Date(value)
|
||||
return new Date(value) as unknown as T
|
||||
}
|
||||
|
||||
if (!isRecordLike(value)) {
|
||||
return cloneInstance(value)
|
||||
}
|
||||
|
||||
const source: RecordLike<unknown> = value as RecordLike<unknown>
|
||||
const copy: RecordLike<unknown> = Array.isArray(source) ? [] : {}
|
||||
|
||||
for (const key in source) {
|
||||
if (has(source, key)) {
|
||||
copy[key] = clone(source[key])
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice().map(clone) as unknown as T
|
||||
}
|
||||
|
||||
return copy
|
||||
const source: Record<string, unknown> = value as Record<string, unknown>
|
||||
|
||||
return Object.keys(source).reduce((copy, key) => ({
|
||||
...copy,
|
||||
[key]: clone(source[key])
|
||||
}), {}) as unknown as T
|
||||
}
|
||||
|
82
src/utils/compare.ts
Normal file
82
src/utils/compare.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import has from './has'
|
||||
import { typeOf, TYPE } from '@/types'
|
||||
|
||||
export interface EqualPredicate {
|
||||
(a: unknown, b: unknown): boolean;
|
||||
}
|
||||
|
||||
export function datesEquals (a: Date, b: Date): boolean {
|
||||
return a.getTime() === b.getTime()
|
||||
}
|
||||
|
||||
export function arraysEquals (
|
||||
a: unknown[],
|
||||
b: unknown[],
|
||||
predicate: EqualPredicate
|
||||
): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!predicate(a[i], b[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function recordsEquals (
|
||||
a: Record<string, unknown>,
|
||||
b: Record<string, unknown>,
|
||||
predicate: EqualPredicate
|
||||
): boolean {
|
||||
if (Object.keys(a).length !== Object.keys(b).length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const prop in a as object) {
|
||||
if (!has(b, prop) || !predicate(a[prop], b[prop])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function strictEquals (a: unknown, b: unknown): boolean {
|
||||
return a === b
|
||||
}
|
||||
|
||||
export function equals (a: unknown, b: unknown, predicate: EqualPredicate): boolean {
|
||||
const typeOfA = typeOf(a)
|
||||
const typeOfB = typeOf(b)
|
||||
|
||||
return typeOfA === typeOfB && (
|
||||
(typeOfA === TYPE.ARRAY && arraysEquals(
|
||||
a as unknown[],
|
||||
b as unknown[],
|
||||
predicate
|
||||
)) ||
|
||||
(typeOfA === TYPE.DATE && datesEquals(a as Date, b as Date)) ||
|
||||
(typeOfA === TYPE.RECORD && recordsEquals(
|
||||
a as Record<string, unknown>,
|
||||
b as Record<string, unknown>,
|
||||
predicate
|
||||
)) ||
|
||||
(typeOfA.includes('InstanceOf') && equals(
|
||||
Object.entries(a as object),
|
||||
Object.entries(b as object),
|
||||
predicate,
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
export function deepEquals (a: unknown, b: unknown): boolean {
|
||||
return a === b || equals(a, b, deepEquals)
|
||||
}
|
||||
|
||||
export function shallowEquals (a: unknown, b: unknown): boolean {
|
||||
return a === b || equals(a, b, strictEquals)
|
||||
}
|
10
src/utils/id.ts
Normal file
10
src/utils/id.ts
Normal file
@ -0,0 +1,10 @@
|
||||
const registry: Map<string, number> = new Map()
|
||||
|
||||
export default (prefix: string): string => {
|
||||
const current = registry.get(prefix) || 0
|
||||
const next = current + 1
|
||||
|
||||
registry.set(prefix, next)
|
||||
|
||||
return `${prefix}-${next}`
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
export { default as id } from './id'
|
||||
export { default as clone } from './clone'
|
||||
export { default as has } from './has'
|
||||
export { default as merge } from './merge'
|
||||
export { get, set, unset } from './access'
|
||||
export { default as regexForFormat } from './regexForFormat'
|
||||
export { default as shallowEquals } from './shallowEquals'
|
||||
export { deepEquals, shallowEquals } from './compare'
|
||||
export { default as snakeToCamel } from './snakeToCamel'
|
||||
|
@ -1,42 +0,0 @@
|
||||
export function equalsDates (a: Date, b: Date): boolean {
|
||||
return a.getTime() === b.getTime()
|
||||
}
|
||||
|
||||
export function shallowEqualsRecords (
|
||||
a: Record<string, unknown>,
|
||||
b: Record<string, unknown>
|
||||
): boolean {
|
||||
const aKeys = Object.keys(a)
|
||||
const bKeys = Object.keys(b)
|
||||
|
||||
if (aKeys.length !== bKeys.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (aKeys.length === 0) {
|
||||
return a === b
|
||||
}
|
||||
|
||||
return aKeys.reduce((equals: boolean, key: string): boolean => {
|
||||
return equals && a[key] === b[key]
|
||||
}, true)
|
||||
}
|
||||
|
||||
export default function shallowEquals (a: unknown, b: unknown): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!a || !b) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (a instanceof Date && b instanceof Date) {
|
||||
return equalsDates(a, b)
|
||||
}
|
||||
|
||||
return shallowEqualsRecords(
|
||||
a as Record<string, unknown>,
|
||||
b as Record<string, unknown>
|
||||
)
|
||||
}
|
@ -130,7 +130,7 @@ const rules: Record<string, ValidationRuleFn> = {
|
||||
* Rule: Value is in an array (stack).
|
||||
*/
|
||||
in ({ value }: ValidationContext, ...stack: any[]): boolean {
|
||||
return stack.some(item => typeof item === 'object' ? shallowEquals(item, value) : item === value)
|
||||
return stack.some(item => shallowEquals(item, value))
|
||||
},
|
||||
|
||||
/**
|
||||
@ -198,7 +198,7 @@ const rules: Record<string, ValidationRuleFn> = {
|
||||
* Rule: Value is not in stack.
|
||||
*/
|
||||
not ({ value }: ValidationContext, ...stack: any[]): boolean {
|
||||
return !stack.some(item => typeof item === 'object' ? shallowEquals(item, value) : item === value)
|
||||
return !stack.some(item => shallowEquals(item, value))
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -11,8 +11,6 @@ export interface Violation {
|
||||
context: ValidationContext|null;
|
||||
}
|
||||
|
||||
export type ViolationsRecord = Record<string, Violation[]>
|
||||
|
||||
export interface ValidationRuleFn {
|
||||
(context: ValidationContext, ...args: any[]): Promise<boolean>|boolean;
|
||||
}
|
||||
|
@ -283,7 +283,6 @@ describe('FormularioField', () => {
|
||||
const form = wrapper.findComponent(FormularioForm)
|
||||
|
||||
expect(form.emitted('input')).toEqual([
|
||||
[{}],
|
||||
[{ date: new Date('2001-05-12') }],
|
||||
])
|
||||
})
|
||||
|
@ -1,50 +0,0 @@
|
||||
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',
|
||||
])
|
||||
})
|
||||
})
|
125
test/unit/utils/compare.test.js
Normal file
125
test/unit/utils/compare.test.js
Normal file
@ -0,0 +1,125 @@
|
||||
import { deepEquals, shallowEquals } from '@/utils/compare'
|
||||
|
||||
class Sample {
|
||||
constructor() {
|
||||
this.fieldA = 'fieldA'
|
||||
this.fieldB = 'fieldB'
|
||||
}
|
||||
|
||||
doSomething () {}
|
||||
}
|
||||
|
||||
describe('compare', () => {
|
||||
describe('deepEquals', () => {
|
||||
test.each`
|
||||
type | a
|
||||
${'booleans'} | ${false}
|
||||
${'numbers'} | ${123}
|
||||
${'strings'} | ${'hello'}
|
||||
${'symbols'} | ${Symbol(123)}
|
||||
${'undefined'} | ${undefined}
|
||||
${'null'} | ${null}
|
||||
${'array'} | ${[1, 2, 3]}
|
||||
${'pojo'} | ${{ a: 1, b: 2 }}
|
||||
${'empty array'} | ${[]}
|
||||
${'empty pojo'} | ${{}}
|
||||
${'date'} | ${new Date()}
|
||||
`('A=A check on $type', ({ a }) => {
|
||||
expect(deepEquals(a, a)).toBe(true)
|
||||
})
|
||||
|
||||
test.each`
|
||||
a | b | expected
|
||||
${[]} | ${[]} | ${true}
|
||||
${[1, 2, 3]} | ${[1, 2, 3]} | ${true}
|
||||
${[1, 2, { a: 1 }]} | ${[1, 2, { a: 1 }]} | ${true}
|
||||
${[1, 2, { a: 1 }]} | ${[1, 2, { a: 2 }]} | ${false}
|
||||
${[1, 2, 3]} | ${[1, 2, 4]} | ${false}
|
||||
${[1, 2, 3]} | ${[1, 2]} | ${false}
|
||||
${[]} | ${[1, 2]} | ${false}
|
||||
${{}} | ${{}} | ${true}
|
||||
${{ a: 1, b: 2 }} | ${{ a: 1, b: 2 }} | ${true}
|
||||
${{ a: 1, b: 2 }} | ${{ a: 1 }} | ${false}
|
||||
${{ a: {} }} | ${{ a: {} }} | ${true}
|
||||
${{ a: { b: 1 } }} | ${{ a: { b: 1 } }} | ${true}
|
||||
${{ a: { b: 1 } }} | ${{ a: { b: 2 } }} | ${false}
|
||||
${new Date()} | ${new Date()} | ${true}
|
||||
`('A=B & B=A check: A=$a, B=$b, expected: $expected', ({ a, b, expected }) => {
|
||||
expect(deepEquals(a, b)).toBe(expected)
|
||||
expect(deepEquals(b, a)).toBe(expected)
|
||||
})
|
||||
|
||||
test('A=B & B=A check for instances', () => {
|
||||
const a = new Sample()
|
||||
const b = new Sample()
|
||||
|
||||
expect(deepEquals(a, b)).toBe(true)
|
||||
expect(deepEquals(b, a)).toBe(true)
|
||||
|
||||
b.fieldA += '~'
|
||||
|
||||
expect(deepEquals(a, b)).toBe(false)
|
||||
expect(deepEquals(b, a)).toBe(false)
|
||||
})
|
||||
|
||||
test('A=B & B=A check for instances with nesting', () => {
|
||||
const a = new Sample()
|
||||
const b = new Sample()
|
||||
|
||||
a.fieldA = new Sample()
|
||||
b.fieldA = new Sample()
|
||||
|
||||
expect(deepEquals(a, b)).toBe(true)
|
||||
expect(deepEquals(b, a)).toBe(true)
|
||||
|
||||
b.fieldA.fieldA += '~'
|
||||
|
||||
expect(deepEquals(a, b)).toBe(false)
|
||||
expect(deepEquals(b, a)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shallowEquals', () => {
|
||||
test.each`
|
||||
type | a
|
||||
${'booleans'} | ${false}
|
||||
${'numbers'} | ${123}
|
||||
${'strings'} | ${'hello'}
|
||||
${'symbols'} | ${Symbol(123)}
|
||||
${'undefined'} | ${undefined}
|
||||
${'null'} | ${null}
|
||||
${'array'} | ${[1, 2, 3]}
|
||||
${'pojo'} | ${{ a: 1, b: 2 }}
|
||||
${'empty array'} | ${[]}
|
||||
${'empty pojo'} | ${{}}
|
||||
${'date'} | ${new Date()}
|
||||
`('A=A check on $type', ({ a }) => {
|
||||
expect(shallowEquals(a, a)).toBe(true)
|
||||
})
|
||||
|
||||
test.each`
|
||||
a | b | expected
|
||||
${[]} | ${[]} | ${true}
|
||||
${[1, 2, 3]} | ${[1, 2, 3]} | ${true}
|
||||
${[1, 2, 3]} | ${[1, 2, 4]} | ${false}
|
||||
${[1, 2, 3]} | ${[1, 2]} | ${false}
|
||||
${[]} | ${[1, 2]} | ${false}
|
||||
${{}} | ${{}} | ${true}
|
||||
${{ a: 1, b: 2 }} | ${{ a: 1, b: 2 }} | ${true}
|
||||
${{ a: 1, b: 2 }} | ${{ a: 1 }} | ${false}
|
||||
${{ a: {} }} | ${{ a: {} }} | ${false}
|
||||
${new Date()} | ${new Date()} | ${true}
|
||||
`('A=B & B=A check: A=$a, B=$b, expected: $expected', ({ a, b, expected }) => {
|
||||
expect(shallowEquals(a, b)).toBe(expected)
|
||||
expect(shallowEquals(b, a)).toBe(expected)
|
||||
})
|
||||
|
||||
test('A=B & B=A check for instances', () => {
|
||||
const a = new Sample()
|
||||
const b = new Sample()
|
||||
|
||||
expect(shallowEquals(a, b)).toBe(false)
|
||||
expect(shallowEquals(b, a)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue
Block a user