1
0
mirror of synced 2024-11-22 13:26:06 +03:00

refactor: moved isScalar to new file, improved typehinting

This commit is contained in:
Zaytsev Kirill 2021-05-22 19:42:36 +03:00
parent 013931fbfc
commit e6271bb069
10 changed files with 144 additions and 111 deletions

View File

@ -52,20 +52,20 @@ type Empty = null | undefined
@Component({ name: 'FormularioField', inheritAttrs: false })
export default class FormularioField extends Vue {
@Inject({ default: undefined }) formularioSetter!: Function|undefined
@Inject({ default: () => (): void => {} }) onFormularioFieldValidation!: Function
@Inject({ default: undefined }) formularioRegister!: Function|undefined
@Inject({ default: undefined }) formularioDeregister!: Function|undefined
@Inject({ default: () => (): Record<string, any> => ({}) }) getFormValues!: Function
@Inject({ default: undefined }) addErrorObserver!: Function|undefined
@Inject({ default: undefined }) removeErrorObserver!: Function|undefined
@Inject({ default: undefined }) __FormularioForm_set!: Function|undefined
@Inject({ default: () => (): void => {} }) __FormularioForm_emitValidation!: Function
@Inject({ default: undefined }) __FormularioForm_register!: Function|undefined
@Inject({ default: undefined }) __FormularioForm_unregister!: Function|undefined
@Inject({ default: () => (): Record<string, unknown> => ({}) }) __FormularioForm_getValue!: () => Record<string, unknown>
@Inject({ default: undefined }) __FormularioForm_addErrorObserver!: Function|undefined
@Inject({ default: undefined }) __FormularioForm_removeErrorObserver!: Function|undefined
@Inject({ default: '' }) path!: string
@Model('input', { default: '' }) value!: any
@Model('input', { default: '' }) value!: unknown
@Prop({
required: true,
validator: (name: any): boolean => typeof name === 'string' && name.length > 0,
validator: (name: unknown): boolean => typeof name === 'string' && name.length > 0,
}) name!: string
@Prop({ default: '' }) validation!: string|any[]
@ -82,22 +82,24 @@ export default class FormularioField extends Vue {
@Prop({ default: () => <U, T>(value: U|Empty): U|T|Empty => value }) modelGetConverter!: ModelGetConverter
@Prop({ default: () => <T, U>(value: U|T): U|T => value }) modelSetConverter!: ModelSetConverter
public proxy: any = this.getInitialValue()
public proxy: unknown = this.getInitialValue()
private localErrors: string[] = []
private violations: Violation[] = []
private validationRun: Promise<any> = Promise.resolve()
get fullQualifiedName (): string {
private violations: Violation[] = []
private validationRun: Promise<Violation[]> = Promise.resolve([])
private get fullQualifiedName (): string {
return this.path !== '' ? `${this.path}.${this.name}` : this.name
}
get model (): any {
private get model (): unknown {
const model = this.hasModel ? 'value' : 'proxy'
return this.modelGetConverter(this[model])
}
set model (value: any) {
private set model (value: unknown) {
value = this.modelSetConverter(value, this.proxy)
if (!shallowEqualObjects(value, this.proxy)) {
@ -106,12 +108,12 @@ export default class FormularioField extends Vue {
this.$emit('input', value)
if (typeof this.formularioSetter === 'function') {
this.formularioSetter(this.context.name, value)
if (typeof this.__FormularioForm_set === 'function') {
this.__FormularioForm_set(this.context.name, value)
}
}
get context (): Context<any> {
private get context (): Context<unknown> {
return Object.defineProperty({
name: this.fullQualifiedName,
runValidation: this.runValidation.bind(this),
@ -120,13 +122,13 @@ export default class FormularioField extends Vue {
allErrors: [...this.localErrors, ...this.violations.map(v => v.message)],
}, 'model', {
get: () => this.model,
set: (value: any) => {
set: (value: unknown) => {
this.model = value
},
})
}
get normalizedValidationRules (): Record<string, ValidationRuleFn> {
private get normalizedValidationRules (): Record<string, ValidationRuleFn> {
const rules: Record<string, ValidationRuleFn> = {}
Object.keys(this.validationRules).forEach(key => {
rules[snakeToCamel(key)] = this.validationRules[key]
@ -134,7 +136,7 @@ export default class FormularioField extends Vue {
return rules
}
get normalizedValidationMessages (): Record<string, ValidationMessageI18NFn|string> {
private get normalizedValidationMessages (): Record<string, ValidationMessageI18NFn|string> {
const messages: Record<string, ValidationMessageI18NFn|string> = {}
Object.keys(this.validationMessages).forEach(key => {
messages[snakeToCamel(key)] = this.validationMessages[key]
@ -145,12 +147,12 @@ export default class FormularioField extends Vue {
/**
* Determines if this formulario element is v-modeled or not.
*/
get hasModel (): boolean {
private get hasModel (): boolean {
return has(this.$options.propsData || {}, 'value')
}
@Watch('proxy')
onProxyChanged (newValue: any, oldValue: any): void {
private onProxyChange (newValue: unknown, oldValue: unknown): void {
if (!this.hasModel && !shallowEqualObjects(newValue, oldValue)) {
this.context.model = newValue
}
@ -162,7 +164,7 @@ export default class FormularioField extends Vue {
}
@Watch('value')
onValueChanged (newValue: any, oldValue: any): void {
private onValueChange (newValue: unknown, oldValue: unknown): void {
if (this.hasModel && !shallowEqualObjects(newValue, oldValue)) {
this.context.model = newValue
}
@ -170,32 +172,31 @@ export default class FormularioField extends Vue {
created (): void {
this.initProxy()
if (typeof this.formularioRegister === 'function') {
this.formularioRegister(this.fullQualifiedName, this)
if (typeof this.__FormularioForm_register === 'function') {
this.__FormularioForm_register(this.fullQualifiedName, this)
}
if (typeof this.addErrorObserver === 'function' && !this.errorsDisabled) {
this.addErrorObserver({ callback: this.setErrors, type: 'input', field: this.fullQualifiedName })
if (typeof this.__FormularioForm_addErrorObserver === 'function' && !this.errorsDisabled) {
this.__FormularioForm_addErrorObserver({ callback: this.setErrors, type: 'field', field: this.fullQualifiedName })
}
if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
this.runValidation()
}
}
// noinspection JSUnusedGlobalSymbols
beforeDestroy (): void {
if (!this.errorsDisabled && typeof this.removeErrorObserver === 'function') {
this.removeErrorObserver(this.setErrors)
if (!this.errorsDisabled && typeof this.__FormularioForm_removeErrorObserver === 'function') {
this.__FormularioForm_removeErrorObserver(this.setErrors)
}
if (typeof this.formularioDeregister === 'function') {
this.formularioDeregister(this.fullQualifiedName)
if (typeof this.__FormularioForm_unregister === 'function') {
this.__FormularioForm_unregister(this.fullQualifiedName)
}
}
getInitialValue (): any {
private getInitialValue (): unknown {
return has(this.$options.propsData || {}, 'value') ? this.value : ''
}
initProxy (): void {
private initProxy (): void {
// This should only be run immediately on created and ensures that the
// proxy and the model are both the same before any additional registration.
if (!shallowEqualObjects(this.context.model, this.proxy)) {
@ -213,8 +214,8 @@ export default class FormularioField extends Vue {
violations: this.violations,
}
this.$emit('validation', payload)
if (typeof this.onFormularioFieldValidation === 'function') {
this.onFormularioFieldValidation(payload)
if (typeof this.__FormularioForm_emitValidation === 'function') {
this.__FormularioForm_emitValidation(payload)
}
}
@ -231,7 +232,7 @@ export default class FormularioField extends Vue {
), {
value: this.context.model,
name: this.context.name,
formValues: this.getFormValues(),
formValues: this.__FormularioForm_getValue(),
})
}

View File

@ -6,9 +6,24 @@
<script lang="ts">
import Vue from 'vue'
import { Component, Model, Prop, Provide, Watch } from 'vue-property-decorator'
import { clone, getNested, has, merge, setNested, shallowEqualObjects } from '@/utils'
import {
Component,
Model,
Prop,
Provide,
Watch,
} from 'vue-property-decorator'
import {
clone,
getNested,
has,
merge,
setNested,
shallowEqualObjects,
} from '@/utils'
import Registry from '@/form/registry'
import FormularioField from '@/FormularioField.vue'
import {
@ -19,20 +34,25 @@ import {
import { Violation } from '@/validation/validator'
type ValidationEventPayload = {
name: string;
violations: Violation[];
}
@Component({ name: 'FormularioForm' })
export default class FormularioForm extends Vue {
@Model('input', { default: () => ({}) })
public readonly formularioValue!: Record<string, any>
public readonly formularioValue!: Record<string, unknown>
// Errors record, describing state validation errors of whole form
@Prop({ default: () => ({}) }) readonly errors!: Record<string, any>
@Prop({ default: () => ({}) }) readonly errors!: Record<string, string[]>
// Form errors only used on FormularioForm default slot
@Prop({ default: () => ([]) }) readonly formErrors!: string[]
@Provide()
public path = ''
public proxy: Record<string, any> = {}
public proxy: Record<string, unknown> = {}
private registry: Registry = new Registry(this)
@ -41,7 +61,7 @@ export default class FormularioForm extends Vue {
private localFormErrors: string[] = []
private localFieldErrors: Record<string, string[]> = {}
get initialValues (): Record<string, any> {
get initialValues (): Record<string, unknown> {
if (this.hasModel && typeof this.formularioValue === 'object') {
// If there is a v-model on the form/group, use those values as first priority
return { ...this.formularioValue } // @todo - use a deep clone to detach reference types
@ -67,7 +87,7 @@ export default class FormularioForm extends Vue {
}
@Watch('formularioValue', { deep: true })
onFormularioValueChanged (values: Record<string, any>): void {
onFormularioValueChanged (values: Record<string, unknown>): void {
if (this.hasModel && values && typeof values === 'object') {
this.setValues(values)
}
@ -80,7 +100,7 @@ export default class FormularioForm extends Vue {
@Watch('mergedFieldErrors', { deep: true, immediate: true })
onMergedFieldErrorsChanged (errors: Record<string, string[]>): void {
this.errorObserverRegistry.filter(o => o.type === 'input').observe(errors)
this.errorObserverRegistry.filter(o => o.type === 'field').observe(errors)
}
created (): void {
@ -88,7 +108,7 @@ export default class FormularioForm extends Vue {
}
@Provide()
getFormValues (): Record<string, any> {
getFormValues (): Record<string, unknown> {
return this.proxy
}
@ -104,12 +124,12 @@ export default class FormularioForm extends Vue {
})
}
@Provide()
onFormularioFieldValidation (payload: { name: string; violations: Violation[]}): void {
@Provide('__FormularioForm_emitValidation')
onFormularioFieldValidation (payload: ValidationEventPayload): void {
this.$emit('validation', payload)
}
@Provide()
@Provide('__FormularioForm_addErrorObserver')
addErrorObserver (observer: ErrorObserver): void {
this.errorObserverRegistry.add(observer)
if (observer.type === 'form') {
@ -119,18 +139,18 @@ export default class FormularioForm extends Vue {
}
}
@Provide()
@Provide('__FormularioForm_removeErrorObserver')
removeErrorObserver (observer: ErrorHandler): void {
this.errorObserverRegistry.remove(observer)
}
@Provide('formularioRegister')
register (field: string, component: FormularioField): void {
@Provide('__FormularioForm_register')
private register (field: string, component: FormularioField): void {
this.registry.add(field, component)
}
@Provide('formularioDeregister')
deregister (field: string): void {
@Provide('__FormularioForm_unregister')
private unregister (field: string): void {
this.registry.remove(field)
}
@ -140,7 +160,7 @@ export default class FormularioForm extends Vue {
}
}
setValues (values: Record<string, any>): void {
setValues (values: Record<string, unknown>): void {
const keys = Array.from(new Set([...Object.keys(values), ...Object.keys(this.proxy)]))
let proxyHasChanges = false
keys.forEach(field => {
@ -148,18 +168,19 @@ export default class FormularioForm extends Vue {
return
}
this.registry.getNested(field).forEach((registryField, registryKey) => {
const $input = this.registry.get(registryKey) as FormularioField
const oldValue = getNested(this.proxy, registryKey)
const newValue = getNested(values, registryKey)
this.registry.getNested(field).forEach((_, fqn) => {
const $field = this.registry.get(fqn) as FormularioField
const oldValue = getNested(this.proxy, fqn)
const newValue = getNested(values, fqn)
if (!shallowEqualObjects(newValue, oldValue)) {
this.setFieldValue(registryKey, newValue)
this.setFieldValue(fqn, newValue)
proxyHasChanges = true
}
if (!shallowEqualObjects(newValue, $input.proxy)) {
$input.context.model = newValue
if (!shallowEqualObjects(newValue, $field.proxy)) {
$field.context.model = newValue
}
})
})
@ -171,7 +192,7 @@ export default class FormularioForm extends Vue {
}
}
setFieldValue (field: string, value: any): void {
setFieldValue (field: string, value: unknown): void {
if (value === undefined) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [field]: value, ...proxy } = this.proxy
@ -181,8 +202,8 @@ export default class FormularioForm extends Vue {
}
}
@Provide('formularioSetter')
setFieldValueAndEmit (field: string, value: any): void {
@Provide('__FormularioForm_set')
setFieldValueAndEmit (field: string, value: unknown): void {
this.setFieldValue(field, value)
this.$emit('input', { ...this.proxy })
}

View File

@ -3,7 +3,7 @@
* If given parameter is not string, object ot array, result will be an empty array.
* @param {*} item
*/
export default function arrayify (item: any): any[] {
export default function arrayify (item: unknown): unknown[] {
if (!item) {
return []
}
@ -14,7 +14,7 @@ export default function arrayify (item: any): any[] {
return item
}
if (typeof item === 'object') {
return Object.values(item)
return Object.values(item as Record<string, unknown>)
}
return []
}

View File

@ -1,25 +1,32 @@
import isScalar from '@/utils/isScalar'
import has from '@/utils/has'
import { RecordLike, Scalar, isScalar } from '@/utils/types'
type Cloneable = Scalar|Date|RecordLike<unknown>
/**
* A simple (somewhat non-comprehensive) clone function, valid for our use
* case of needing to unbind reactive watchers.
*/
export default function clone (value: any): any {
if (typeof value !== 'object') {
return value
export default function clone (value: Cloneable): Cloneable {
if (isScalar(value)) {
return value as Scalar
}
const copy: any | Record<string, any> = Array.isArray(value) ? [] : {}
if (value instanceof Date) {
return new Date(value)
}
for (const key in value) {
if (has(value, key)) {
if (isScalar(value[key])) {
copy[key] = value[key]
} else if (value instanceof Date) {
copy[key] = new Date(copy[key])
const source: RecordLike<unknown> = value as RecordLike<unknown>
const copy: RecordLike<unknown> = Array.isArray(source) ? [] : {}
for (const key in source) {
if (has(source, key)) {
if (isScalar(source[key])) {
copy[key] = source[key]
} else if (source[key] instanceof Date) {
copy[key] = new Date(source[key] as Date)
} else {
copy[key] = clone(value[key])
copy[key] = clone(source[key] as Cloneable)
}
}
}

View File

@ -1,7 +1,7 @@
export { default as arrayify } from './arrayify'
export { default as clone } from './clone'
export { default as has } from './has'
export { default as isScalar } from './isScalar'
export { isScalar } from './types'
export { default as merge } from './merge'
export { default as regexForFormat } from './regexForFormat'
export { default as shallowEqualObjects } from './shallowEqualObjects'

View File

@ -1,12 +0,0 @@
export default function isScalar (data: any): boolean {
switch (typeof data) {
case 'symbol':
case 'number':
case 'string':
case 'boolean':
case 'undefined':
return true
default:
return data === null
}
}

17
src/utils/types.ts Normal file
View File

@ -0,0 +1,17 @@
export type RecordKey = string | number
export type RecordLike<T> = T[] | Record<RecordKey, T>
export type Scalar = boolean | number | string | symbol | undefined | null
export function isScalar (value: unknown): boolean {
switch (typeof value) {
case 'boolean':
case 'number':
case 'string':
case 'symbol':
case 'undefined':
return true
default:
return value === null
}
}

View File

@ -6,10 +6,12 @@ export interface ErrorHandler {
export interface ErrorObserver {
callback: ErrorHandler;
type: 'form' | 'field';
type: ErrorObserverType;
field?: string;
}
export type ErrorObserverType = 'form' | 'field'
export interface ErrorObserverPredicate {
(value: ErrorObserver, index: number, array: ErrorObserver[]): unknown;
}

View File

@ -1,17 +0,0 @@
import isScalar from '@/utils/isScalar'
describe('isScalar', () => {
it('Passes on strings', () => expect(isScalar('hello')).toBe(true))
it('Passes on numbers', () => expect(isScalar(123)).toBe(true))
it('Passes on booleans', () => expect(isScalar(false)).toBe(true))
it('Passes on symbols', () => expect(isScalar(Symbol(123))).toBe(true))
it('Passes on null', () => expect(isScalar(null)).toBe(true))
it('Passes on undefined', () => expect(isScalar(undefined)).toBe(true))
it('Fails on pojo', () => expect(isScalar({})).toBe(false))
})

View File

@ -0,0 +1,14 @@
import { isScalar } from '@/utils/types'
describe('isScalar', () => {
const expectIsScalar = value => expect(isScalar(value)).toBe(true)
test('passes on booleans', () => expectIsScalar(false))
test('passes on numbers', () => expectIsScalar(123))
test('passes on strings', () => expectIsScalar('hello'))
test('passes on symbols', () => expectIsScalar(Symbol(123)))
test('passes on undefined', () => expectIsScalar(undefined))
test('passes on null', () => expectIsScalar(null))
test('fails on pojo', () => expect(isScalar({})).toBe(false))
})