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

View File

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

View File

@ -1,25 +1,32 @@
import isScalar from '@/utils/isScalar'
import has from '@/utils/has' 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 * 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: any): any { export default function clone (value: Cloneable): Cloneable {
if (typeof value !== 'object') { if (isScalar(value)) {
return 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) { const source: RecordLike<unknown> = value as RecordLike<unknown>
if (has(value, key)) { const copy: RecordLike<unknown> = Array.isArray(source) ? [] : {}
if (isScalar(value[key])) {
copy[key] = value[key] for (const key in source) {
} else if (value instanceof Date) { if (has(source, key)) {
copy[key] = new Date(copy[key]) if (isScalar(source[key])) {
copy[key] = source[key]
} else if (source[key] instanceof Date) {
copy[key] = new Date(source[key] as Date)
} else { } 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 arrayify } from './arrayify'
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 isScalar } from './isScalar' export { isScalar } from './types'
export { default as merge } from './merge' export { default as merge } from './merge'
export { default as regexForFormat } from './regexForFormat' export { default as regexForFormat } from './regexForFormat'
export { default as shallowEqualObjects } from './shallowEqualObjects' 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 { export interface ErrorObserver {
callback: ErrorHandler; callback: ErrorHandler;
type: 'form' | 'field'; type: ErrorObserverType;
field?: string; field?: string;
} }
export type ErrorObserverType = 'form' | 'field'
export interface ErrorObserverPredicate { export interface ErrorObserverPredicate {
(value: ErrorObserver, index: number, array: ErrorObserver[]): unknown; (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))
})