1
0
mirror of synced 2024-12-01 09:36:09 +03:00

refactor: Deep cloning of state, deep equal checker

This commit is contained in:
Zaytsev Kirill 2021-06-11 20:32:46 +03:00
parent 7c2a9e8110
commit b37040d2d3
16 changed files with 395 additions and 353 deletions

View File

@ -7,10 +7,10 @@ import {
ValidationRuleFn, ValidationRuleFn,
ValidationMessageFn, ValidationMessageFn,
ValidationMessageI18NFn, ValidationMessageI18NFn,
ViolationsRecord, Violation,
} from '@/validation/validator' } from '@/validation/validator'
import { FormularioFormInterface } from '@/types' import { FormularioForm } from '@/types'
export interface FormularioOptions { export interface FormularioOptions {
validationRules?: Record<string, ValidationRuleFn>; validationRules?: Record<string, ValidationRuleFn>;
@ -24,7 +24,7 @@ export default class Formulario {
public validationRules: Record<string, ValidationRuleFn> = {} public validationRules: Record<string, ValidationRuleFn> = {}
public validationMessages: Record<string, ValidationMessageI18NFn|string> = {} public validationMessages: Record<string, ValidationMessageI18NFn|string> = {}
private readonly registry: Map<string, FormularioFormInterface> private readonly registry: Map<string, FormularioForm>
public constructor (options?: FormularioOptions) { public constructor (options?: FormularioOptions) {
this.registry = new Map() 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})`) 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)) { 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}"`)
} }
const form = this.registry.get(id) as FormularioFormInterface const form = this.registry.get(id) as FormularioForm
return form.runValidation() return form.runValidation()
} }
@ -62,7 +62,7 @@ export default class Formulario {
return return
} }
const form = this.registry.get(id) as FormularioFormInterface const form = this.registry.get(id) as FormularioForm
form.resetValidation() form.resetValidation()
} }
@ -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
*/ */
public register (id: string, form: FormularioFormInterface): void { public register (id: string, form: FormularioForm): 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`)
} }

View File

@ -13,7 +13,7 @@ import {
Prop, Prop,
Watch, Watch,
} from 'vue-property-decorator' } from 'vue-property-decorator'
import { has, shallowEquals, snakeToCamel } from './utils' import { deepEquals, has, snakeToCamel } from './utils'
import { import {
processConstraints, processConstraints,
validate, validate,
@ -87,35 +87,18 @@ export default class FormularioField extends Vue {
return has(this.$options.propsData || {}, 'value') 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> { private get context (): FormularioFieldContext<unknown> {
return Object.defineProperty({ return Object.defineProperty({
name: this.fullPath, name: this.fullPath,
path: this.fullPath,
runValidation: this.runValidation.bind(this), runValidation: this.runValidation.bind(this),
violations: this.violations, violations: this.violations,
errors: this.localErrors, errors: this.localErrors,
allErrors: [...this.localErrors, ...this.violations.map(v => v.message)], allErrors: [...this.localErrors, ...this.violations.map(v => v.message)],
}, 'model', { }, 'model', {
get: () => this.model, get: () => this.modelGetConverter(this.proxy),
set: (value: unknown) => { set: (value: unknown): void => {
this.model = value this.syncProxy(this.modelSetConverter(value, this.proxy))
}, },
}) })
} }
@ -137,18 +120,12 @@ export default class FormularioField extends Vue {
} }
@Watch('value') @Watch('value')
private onValueChange (newValue: unknown, oldValue: unknown): void { private onValueChange (): void {
if (this.hasModel && !shallowEquals(newValue, oldValue)) { this.syncProxy(this.value)
this.model = newValue
}
} }
@Watch('proxy') @Watch('proxy')
private onProxyChange (newValue: unknown, oldValue: unknown): void { private onProxyChange (): void {
if (!this.hasModel && !shallowEquals(newValue, oldValue)) {
this.model = newValue
}
if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) { if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
this.runValidation() this.runValidation()
} else { } else {
@ -160,10 +137,6 @@ export default class FormularioField extends Vue {
* @internal * @internal
*/ */
public created (): void { public created (): void {
if (!shallowEquals(this.model, this.proxy)) {
this.model = this.proxy
}
if (typeof this.__FormularioForm_register === 'function') { if (typeof this.__FormularioForm_register === 'function') {
this.__FormularioForm_register(this.fullPath, this) this.__FormularioForm_register(this.fullPath, this)
} }
@ -182,13 +155,22 @@ export default class FormularioField extends Vue {
} }
} }
public runValidation (): Promise<Violation[]> { private syncProxy (value: unknown): void {
this.validationRun = this.validate().then(violations => { if (!deepEquals(value, this.proxy)) {
if (!shallowEquals(this.violations, violations)) { this.proxy = value
this.emitValidation(this.fullPath, violations) 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 => {
this.violations = violations this.violations = violations
this.emitValidation(this.fullPath, violations)
return this.violations return this.violations
}) })
@ -202,8 +184,8 @@ export default class FormularioField extends Vue {
this.$formulario.getRules(this.normalizedValidationRules), this.$formulario.getRules(this.normalizedValidationRules),
this.$formulario.getMessages(this, this.normalizedValidationMessages), this.$formulario.getMessages(this, this.normalizedValidationMessages),
), { ), {
value: this.context.model, value: this.proxy,
name: this.context.name, name: this.fullPath,
formValues: this.__FormularioForm_getState(), formValues: this.__FormularioForm_getState(),
}) })
} }

View File

@ -22,17 +22,17 @@ export default class FormularioFieldGroup extends Vue {
@Provide('__Formulario_path') @Provide('__Formulario_path')
get fullPath (): string { get fullPath (): string {
const name = `${this.name}` const path = `${this.name}`
if (parseInt(name).toString() === name) { if (parseInt(path).toString() === path) {
return `${this.__Formulario_path}[${name}]` return `${this.__Formulario_path}[${path}]`
} }
if (this.__Formulario_path === '') { if (this.__Formulario_path === '') {
return name return path
} }
return `${this.__Formulario_path}.${name}` return `${this.__Formulario_path}.${path}`
} }
} }
</script> </script>

View File

@ -14,67 +14,51 @@ import {
Watch, Watch,
} from 'vue-property-decorator' } from 'vue-property-decorator'
import { import {
id,
clone, clone,
deepEquals,
get, get,
has, has,
merge, merge,
set, set,
shallowEquals,
unset, unset,
} from '@/utils' } from '@/utils'
import PathRegistry from '@/PathRegistry' import { FormularioField } from '@/types'
import { Violation } from '@/validation/validator'
import { FormularioFieldInterface } from '@/types'
import {
Violation,
ViolationsRecord,
} from '@/validation/validator'
type ErrorsRecord = Record<string, string[]>
type ValidationEventPayload = { type ValidationEventPayload = {
name: string; name: string;
violations: Violation[]; 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' }) @Component({ name: 'FormularioForm' })
export default class FormularioForm extends Vue { export default class FormularioForm extends Vue {
@Model('input', { default: () => ({}) }) @Model('input', { default: () => ({}) })
public readonly state!: Record<string, unknown> public readonly state!: Record<string, unknown>
@Prop({ default: () => `formulario-form-${++counter}` }) @Prop({ default: () => id('formulario-form') })
public readonly id!: string public readonly id!: string
// Describes validation errors of whole form // Describes validation errors of whole form
@Prop({ default: () => ({}) }) readonly fieldsErrors!: ErrorsRecord @Prop({ default: () => ({}) }) readonly fieldsErrors!: Record<string, string[]>
// Only used on FormularioForm default slot // Only used on FormularioForm default slot
@Prop({ default: () => ([]) }) readonly formErrors!: string[] @Prop({ default: () => ([]) }) readonly formErrors!: string[]
private proxy: Record<string, unknown> = {} 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 // Local error messages are temporal, they wiped each resetValidation call
private localFieldsErrors: ErrorsRecord = {} private localFieldsErrors: Record<string, string[]> = {}
private localFormErrors: 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[]> { private get fieldsErrorsComputed (): Record<string, string[]> {
return merge(this.fieldsErrors || {}, this.localFieldsErrors) return merge(this.fieldsErrors || {}, this.localFieldsErrors)
} }
@ -84,19 +68,21 @@ export default class FormularioForm extends Vue {
} }
@Provide('__FormularioForm_register') @Provide('__FormularioForm_register')
private register (path: string, field: FormularioFieldInterface): void { private register (path: string, field: FormularioField): void {
this.registry.add(path, field) 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) { if (value !== undefined) {
field.model = value field.proxy = value
} else { } else {
this.setFieldValue(path, null) this.setFieldValue(path, null)
this.emitInput() this.emitInput()
} }
} else if (field.hasModel && !shallowEquals(field.proxy, value)) { } else if (!deepEquals(field.proxy, value)) {
this.setFieldValue(path, field.proxy) this.setFieldValue(path, field.proxy)
this.emitInput() this.emitInput()
} }
@ -109,10 +95,9 @@ export default class FormularioForm extends Vue {
@Provide('__FormularioForm_unregister') @Provide('__FormularioForm_unregister')
private unregister (path: string): void { private unregister (path: string): void {
if (this.registry.has(path)) { if (this.registry.has(path)) {
this.registry.remove(path) this.registry.delete(path)
// eslint-disable-next-line @typescript-eslint/no-unused-vars this.proxy = unset(this.proxy, path) as Record<string, unknown>
const { [path]: _, ...newProxy } = this.proxy this.emitInput()
this.proxy = newProxy
} }
} }
@ -123,16 +108,12 @@ export default class FormularioForm extends Vue {
@Provide('__FormularioForm_set') @Provide('__FormularioForm_set')
private setFieldValue (path: string, value: unknown): void { private setFieldValue (path: string, value: unknown): void {
if (value === undefined) { this.proxy = update(this.proxy, path, value)
this.proxy = unset(this.proxy, path) as Record<string, unknown>
} else {
this.proxy = set(this.proxy, path, value) as Record<string, unknown>
}
} }
@Provide('__FormularioForm_emitInput') @Provide('__FormularioForm_emitInput')
private emitInput (): void { private emitInput (): void {
this.$emit('input', { ...this.proxy }) this.$emit('input', clone(this.proxy))
} }
@Provide('__FormularioForm_emitValidation') @Provide('__FormularioForm_emitValidation')
@ -141,9 +122,29 @@ export default class FormularioForm extends Vue {
} }
@Watch('state', { deep: true }) @Watch('state', { deep: true })
private onStateChange (state: Record<string, unknown>): void { private onStateChange (newState: Record<string, unknown>): void {
if (this.hasModel && state && typeof state === 'object') { const newProxy = clone(newState)
this.loadState(state) 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 { public created (): void {
this.syncProxy()
this.$formulario.register(this.id, this) this.$formulario.register(this.id, this)
if (typeof this.state === 'object') {
this.proxy = clone(this.state)
}
} }
public beforeDestroy (): void { public beforeDestroy (): void {
this.$formulario.unregister(this.id) this.$formulario.unregister(this.id)
} }
public runValidation (): Promise<ViolationsRecord> { public runValidation (): Promise<Record<string, Violation[]>> {
const violations: ViolationsRecord = {} const runs: Promise<void>[] = []
const runs = this.registry.map((field: FormularioFieldInterface, path: string) => { const violations: Record<string, Violation[]> = {}
return field.runValidation().then(v => { violations[path] = v })
this.registry.forEach((field, path) => {
runs.push(field.runValidation().then(v => { violations[path] = v }))
}) })
return Promise.all(runs).then(() => violations) 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.localFieldsErrors = fieldsErrors || {}
this.localFormErrors = formErrors || [] this.localFormErrors = formErrors || []
} }
@ -186,14 +194,13 @@ export default class FormularioForm extends Vue {
public resetValidation (): void { public resetValidation (): void {
this.localFieldsErrors = {} this.localFieldsErrors = {}
this.localFormErrors = [] this.localFormErrors = []
this.registry.forEach((field: FormularioFieldInterface) => { this.registry.forEach((field: FormularioField) => {
field.resetValidation() field.resetValidation()
}) })
} }
private onSubmit (): Promise<void> { private onSubmit (): Promise<void> {
return this.runValidation() return this.runValidation().then(violations => {
.then(violations => {
const hasErrors = Object.keys(violations).some(path => violations[path].length > 0) const hasErrors = Object.keys(violations).some(path => violations[path].length > 0)
if (!hasErrors) { if (!hasErrors) {
@ -203,46 +210,5 @@ export default class FormularioForm extends Vue {
} }
}) })
} }
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
}
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> </script>

View File

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

View File

@ -1,13 +1,13 @@
import { Violation, ViolationsRecord } from '@/validation/validator' import Vue from 'vue'
import { Violation } from '@/validation/validator'
export interface FormularioFormInterface { export interface FormularioForm extends Vue {
runValidation(): Promise<ViolationsRecord>; runValidation(): Promise<Record<string, Violation[]>>;
resetValidation(): void; resetValidation(): void;
} }
export interface FormularioFieldInterface { export interface FormularioField extends Vue {
hasModel: boolean; hasModel: boolean;
model: unknown;
proxy: unknown; proxy: unknown;
setErrors(errors: string[]): void; setErrors(errors: string[]): void;
runValidation(): Promise<Violation[]>; runValidation(): Promise<Violation[]>;
@ -33,10 +33,58 @@ export interface FormularioFieldModelSetConverter {
export type Empty = undefined | null export type Empty = undefined | null
export type RecordKey = string | number export enum TYPE {
export type RecordLike<T> = T[] | Record<RecordKey, T> 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 { export function isRecordLike (value: unknown): boolean {
return typeof value === 'object' && value !== null && ['Array', 'Object'].includes(value.constructor.name) return typeof value === 'object' && value !== null && ['Array', 'Object'].includes(value.constructor.name)

View File

@ -1,10 +1,4 @@
import has from '@/utils/has' import { isRecordLike, isScalar } from '@/types'
import {
RecordLike,
Scalar,
isRecordLike,
isScalar,
} from '@/types'
const cloneInstance = <T>(original: T): T => { const cloneInstance = <T>(original: T): T => {
return Object.assign(Object.create(Object.getPrototypeOf(original)), original) 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 * A simple (somewhat non-comprehensive) clone function, valid for our use
* case of needing to unbind reactive watchers. * 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)) { if (isScalar(value)) {
return value as Scalar return value
} }
if (value instanceof Date) { if (value instanceof Date) {
return new Date(value) return new Date(value) as unknown as T
} }
if (!isRecordLike(value)) { if (!isRecordLike(value)) {
return cloneInstance(value) return cloneInstance(value)
} }
const source: RecordLike<unknown> = value as RecordLike<unknown> if (Array.isArray(value)) {
const copy: RecordLike<unknown> = Array.isArray(source) ? [] : {} return value.slice().map(clone) as unknown as T
for (const key in source) {
if (has(source, key)) {
copy[key] = clone(source[key])
}
} }
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
View 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
View 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}`
}

View File

@ -1,7 +1,8 @@
export { default as id } from './id'
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, set, 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 { deepEquals, shallowEquals } from './compare'
export { default as snakeToCamel } from './snakeToCamel' export { default as snakeToCamel } from './snakeToCamel'

View File

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

View File

@ -130,7 +130,7 @@ const rules: Record<string, ValidationRuleFn> = {
* Rule: Value is in an array (stack). * Rule: Value is in an array (stack).
*/ */
in ({ value }: ValidationContext, ...stack: any[]): boolean { 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. * Rule: Value is not in stack.
*/ */
not ({ value }: ValidationContext, ...stack: any[]): boolean { 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))
}, },
/** /**

View File

@ -11,8 +11,6 @@ export interface Violation {
context: ValidationContext|null; context: ValidationContext|null;
} }
export type ViolationsRecord = Record<string, Violation[]>
export interface ValidationRuleFn { export interface ValidationRuleFn {
(context: ValidationContext, ...args: any[]): Promise<boolean>|boolean; (context: ValidationContext, ...args: any[]): Promise<boolean>|boolean;
} }

View File

@ -283,7 +283,6 @@ describe('FormularioField', () => {
const form = wrapper.findComponent(FormularioForm) const form = wrapper.findComponent(FormularioForm)
expect(form.emitted('input')).toEqual([ expect(form.emitted('input')).toEqual([
[{}],
[{ date: new Date('2001-05-12') }], [{ date: new Date('2001-05-12') }],
]) ])
}) })

View File

@ -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',
])
})
})

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