From 886634db36d0afa97413f85755a80556bba5a9e9 Mon Sep 17 00:00:00 2001 From: Hanxing Yang Date: Thu, 2 Jul 2020 19:16:53 +0800 Subject: [PATCH] add adapter for formstate (#28) * add adapter for formstate * eliminate duplicated code * property origin for xified state * update README * use state.hasError instead of state.error * v1.2.0 * remove nouse comment --- package-lock.json | 8 +- package.json | 3 +- src/adapter/README.md | 29 +++ src/adapter/index.spec.ts | 443 ++++++++++++++++++++++++++++++++++++++ src/adapter/index.ts | 160 ++++++++++++++ 5 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 src/adapter/README.md create mode 100644 src/adapter/index.spec.ts create mode 100644 src/adapter/index.ts diff --git a/package-lock.json b/package-lock.json index 5237718..94cfa13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "formstate-x", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1713,6 +1713,12 @@ "mime-types": "^2.1.12" } }, + "formstate": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/formstate/-/formstate-1.3.0.tgz", + "integrity": "sha512-7WHpxTmKRbG2mkPzZrJU0bQi2SA3FKCjTLqDQHNoKiF2PI/ObBf3EYavPdga8TqXG/npK/voQe5vWarHjQHs2A==", + "dev": true + }, "fragment-cache": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", diff --git a/package.json b/package.json index adb9daa..ab567d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "formstate-x", - "version": "1.1.0", + "version": "1.2.0", "description": "Extended alternative for formstate", "repository": { "type": "git", @@ -33,6 +33,7 @@ "devDependencies": { "@types/jest": "^25.2.1", "@types/node": "^12.7.12", + "formstate": "^1.0.0", "jest": "^25.2.7", "mobx": "^5.15.4", "ts-jest": "^25.3.1", diff --git a/src/adapter/README.md b/src/adapter/README.md new file mode 100644 index 0000000..3f5a1f4 --- /dev/null +++ b/src/adapter/README.md @@ -0,0 +1,29 @@ +# formstate adapter + +This module provides a [formstate](https://github.com/formstate/formstate) adapter, with which you can use formstate state in formstate-x `FormState` like this: + +```ts +import * as fs from 'formstate' +import { FormState } from 'formstate-x' + +// the adapt method +import { xify } from 'formstate-x/esm/adapter' + +// formstate FieldState or FormState +const stateA = new fs.FieldState(1) +const stateB = new fs.FormState({ ... }) + +// formstate-x FormState +const formState = new FormState({ + a: xify(state), + b: xify(stateB) +}) + +// you can use the form state as usual +console.log(formState.value) +const result = await formState.validate() +``` + +It is helpful when migrating your project from formstate to formstate-x. Instead of rewriting all your input / field components at once, you can do it one by one. The adapter makes it possible to use formstate-based input / field component in a formstate-x-based form / page component. + +**NOTE: [`FormStateLazy`](https://formstate.github.io/#/?id=formstatelazy) is not supported yet.** diff --git a/src/adapter/index.spec.ts b/src/adapter/index.spec.ts new file mode 100644 index 0000000..fee6c8c --- /dev/null +++ b/src/adapter/index.spec.ts @@ -0,0 +1,443 @@ +import * as fs from 'formstate' +import * as fsx from '..' +import { xify, getValue, getValidateStatus, getDirty, getActivated } from '.' + +const stableDelay = 200 * 2 + 10 // [onChange debounce] + [async validate] + [buffer] + +async function delay(millisecond: number = stableDelay) { + await new Promise(resolve => setTimeout(() => resolve(), millisecond)) +} + +describe('xify', () => { + + it('should work well', () => { + const stateName = xify(new fs.FieldState('foo')) + const statePos = xify(new fs.FormState({ + x: new fs.FieldState(10), + y: new fs.FieldState(20) + })) + const state = new fsx.FormState({ + name: stateName, + pos: statePos + }) + expect(state.value).toEqual({ + name: 'foo', + pos: { x: 10, y: 20 } + }) + }) + + it('should work well with field state', () => { + const state = new fs.FieldState(0) + const stateX = xify(state) + expect(stateX.value).toBe(0) + expect(stateX.$).toBe(0) + expect(stateX.origin).toBe(state) + }) + + it('should work well with form state', () => { + const state = new fs.FormState({ + a: new fs.FieldState('a'), + b: new fs.FormState({ + c: new fs.FieldState(1) + }) + }) + const stateX = xify(state) + expect(stateX.value).toEqual({ a: 'a', b: { c: 1 } }) + expect(stateX.$).toEqual({ a: 'a', b: { c: 1 } }) + expect(stateX.origin).toBe(state) + }) + + it('should work well with form state of mode "array"', () => { + const stateX = xify(new fs.FormState([ + new fs.FieldState('a'), + new fs.FormState({ + c: new fs.FieldState(1) + }) + ])) + expect(stateX.value).toEqual(['a', { c: 1 }]) + expect(stateX.$).toEqual(['a', { c: 1 }]) + }) + + it('should work well with field state\'s error', async () => { + const state = new fs.FieldState(0).validators(v => v !== 0 && 'expect zero') + const stateX = xify(state) + expect(stateX.hasError).toBe(false) + expect(stateX.error == null).toBe(true) + + state.onChange(1) + await state.validate() + expect(stateX.hasError).toBe(true) + expect(stateX.error).toBe('expect zero') + + state.onChange(0) + await state.validate() + expect(stateX.hasError).toBe(false) + expect(stateX.error == null).toBe(true) + }) + + it('should work well with form state\'s error', async () => { + const state = new fs.FormState({ + num: new fs.FieldState(0).validators(v => v !== 0 && 'expect zero') + }) + const stateX = xify(state) + expect(stateX.hasError).toBe(false) + expect(stateX.error == null).toBe(true) + + state.$.num.onChange(1) + await state.validate() + expect(stateX.hasError).toBe(true) + expect(stateX.error).toBe('expect zero') + + state.$.num.onChange(0) + await state.validate() + expect(stateX.hasError).toBe(false) + expect(stateX.error == null).toBe(true) + }) + + it('should work well with field state\'s validate status (sync validator)', async () => { + const state = new fs.FieldState(0).validators(v => v !== 0 && 'expect zero') + const stateX = xify(state) + expect(stateX._validateStatus).toBe(fsx.ValidateStatus.NotValidated) + + state.onChange(1) + await state.validate() + expect(stateX._validateStatus).toBe(fsx.ValidateStatus.Validated) + + state.onChange(0) + await state.validate() + expect(stateX._validateStatus).toBe(fsx.ValidateStatus.Validated) + + stateX.reset() + expect(stateX._validateStatus).toBe(fsx.ValidateStatus.NotValidated) + }) + + it('should work well with field state\'s validate status (async validator)', async () => { + const state = new fs.FieldState(0).validators(async v => { + await delay(100) + return v !== 0 && 'expect zero' + }) + const stateX = xify(state) + expect(stateX._validateStatus).toBe(fsx.ValidateStatus.NotValidated) + + state.onChange(1) + const validated = state.validate() + expect(stateX._validateStatus).toBe(fsx.ValidateStatus.Validating) + await validated + expect(stateX._validateStatus).toBe(fsx.ValidateStatus.Validated) + }) + + it('should work well with form state\'s validate status', async () => { + const numState = new fs.FieldState(0).validators(async v => { + await delay(100) + return v !== 0 && 'expect zero' + }) + const state = new fs.FormState({ + num: numState + }) + const stateX = xify(state) + expect(stateX._validateStatus).toBe(fsx.ValidateStatus.NotValidated) + + state.$.num.onChange(1) + const validated = state.validate() + expect(stateX._validateStatus).toBe(fsx.ValidateStatus.Validating) + await validated + expect(stateX._validateStatus).toBe(fsx.ValidateStatus.Validated) + }) + + it('should work well with field state\'s $', async () => { + const state = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const stateX = xify(state) + expect(stateX.$).toBe(1) + + state.onChange(0) + await state.validate() + expect(stateX.$).toBe(1) + + state.onChange(2) + const validated = state.validate() + expect(stateX.$).toBe(1) + await validated + expect(stateX.$).toBe(2) + }) + + it('should work well with form state\'s $', async () => { + const numState = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const state = new fs.FormState({ + num: numState + }) + const stateX = xify(state) + expect(stateX.$).toEqual({ num: 1 }) + + state.$.num.onChange(0) + await state.validate() + expect(stateX.$).toEqual({ num: 1 }) + + state.$.num.onChange(2) + const validated = state.validate() + expect(stateX.$).toEqual({ num: 1 }) + await validated + expect(stateX.$).toEqual({ num: 2 }) + }) + + it('should work well with field state\'s validate()', async () => { + const state = new fs.FieldState(0).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const stateX = xify(state) + let res = await stateX.validate() + expect(res).toEqual({ hasError: true, error: 'positive requried' }) + expect(stateX.hasError).toBe(true) + expect(stateX.error).toBe('positive requried') + + state.onChange(1) + res = await stateX.validate() + expect(res).toEqual({ hasError: false, value: 1 }) + expect(stateX.hasError).toBe(false) + expect(stateX.error == null).toBe(true) + }) + + it('should work well with form state\'s validate()', async () => { + const numState = new fs.FieldState(0).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const state = new fs.FormState({ + num: numState + }) + const stateX = xify(state) + let res = await stateX.validate() + expect(res).toEqual({ hasError: true, error: 'positive requried' }) + expect(stateX.hasError).toBe(true) + expect(stateX.error).toBe('positive requried') + + state.$.num.onChange(1) + res = await stateX.validate() + expect(res).toEqual({ hasError: false, value: { num: 1 } }) + expect(stateX.hasError).toBe(false) + expect(stateX.error == null).toBe(true) + }) + + it('should work well with field state\'s reset()', async () => { + const state = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const stateX = xify(state) + state.onChange(0) + await stateX.validate() + stateX.reset() + + expect(stateX.validating).toBe(false) + expect(stateX.validated).toBe(false) + expect(stateX.value).toBe(1) + expect(stateX.$).toBe(1) + expect(stateX.hasError).toBe(false) + expect(stateX.error).toBeUndefined() + expect(stateX._activated).toBe(false) + }) + + it('should work well with form state\'s reset()', async () => { + const numState = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const state = new fs.FormState({ + num: numState + }) + const stateX = xify(state) + state.$.num.onChange(0) + await stateX.validate() + stateX.reset() + + expect(stateX.validating).toBe(false) + expect(stateX.validated).toBe(false) + expect(stateX.value).toEqual({ num: 1 }) + expect(stateX.$).toEqual({ num: 1 }) + expect(stateX.hasError).toBe(false) + expect(stateX.error).toBeUndefined() + expect(stateX.dirty).toBe(false) + expect(stateX._activated).toBe(false) + }) + + it('should work well with field state\'s dispose()', () => { + const state = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const stateX = xify(state) + stateX.dispose() + }) + + it('should work well with form state\'s dispose()', () => { + const numState = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const state = new fs.FormState({ + num: numState + }) + const stateX = xify(state) + stateX.dispose() + }) + + it('should work well with field state\'s dirty', async () => { + const state = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const stateX = xify(state) + expect(stateX.dirty).toBe(false) + state.onChange(0) + expect(stateX.dirty).toBe(true) + await stateX.validate() + expect(stateX.dirty).toBe(true) + }) + + it('should work well with form state\'s dirty', async () => { + const numState = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const state = new fs.FormState({ + num: numState + }) + const stateX = xify(state) + expect(stateX.dirty).toBe(false) + state.$.num.onChange(0) + expect(stateX.dirty).toBe(true) + await stateX.validate() + expect(stateX.dirty).toBe(true) + }) + + it('should work well with field state\'s _activated', async () => { + const state = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const stateX = xify(state) + expect(stateX._activated).toBe(false) + state.onChange(0) + expect(stateX._activated).toBe(true) + await stateX.validate() + expect(stateX._activated).toBe(true) + }) + + it('should work well with form state\'s _activated', async () => { + const numState = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const state = new fs.FormState({ + num: numState + }) + const stateX = xify(state) + expect(stateX._activated).toBe(false) + state.$.num.onChange(0) + expect(stateX._activated).toBe(true) + await stateX.validate() + expect(stateX._activated).toBe(true) + }) + + it('should throw with FormStateLazy', () => { + const numState = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const state = new fs.FormStateLazy(() => [numState]) + expect(() => { + const stateX = xify(state as any) + console.log(stateX.value) + }).toThrow() + }) + + it('should throw with formstate of mode "map"', () => { + const numState = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const state = new fs.FormState(new Map([ + ['num', numState] + ])) + + expect(() => { + const stateX = xify(state) + console.log(stateX.value) + }).toThrow() + }) + + it('should throw with formstate which contains formstate of mode "map"', () => { + const numState = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const mapState = new fs.FormState(new Map([ + ['num', numState] + ])) + const state = new fs.FormState({ + map: mapState + }) + expect(() => { + const stateX = xify(state) + console.log(stateX.value) + }).toThrow() + }) +}) + +describe('getValue', () => { + it('should throw with FormStateLazy', () => { + const numState = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const state = new fs.FormStateLazy(() => [numState]) + + expect(() => getValue(state as any, true)).toThrow() + expect(() => getValue(state as any, false)).toThrow() + }) +}) + +describe('getValidateStatus', () => { + it('should throw with FormStateLazy', () => { + const numState = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const state = new fs.FormStateLazy(() => [numState]) + + expect(() => getValidateStatus(state as any)).toThrow() + expect(() => getValidateStatus(state as any)).toThrow() + }) +}) + +describe('getDirty', () => { + it('should throw with FormStateLazy', () => { + const numState = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const state = new fs.FormStateLazy(() => [numState]) + + expect(() => getDirty(state as any)).toThrow() + expect(() => getDirty(state as any)).toThrow() + }) +}) + +describe('getActivated', () => { + it('should throw with FormStateLazy', () => { + const numState = new fs.FieldState(1).validators(async v => { + await delay(100) + return v <= 0 && 'positive requried' + }) + const state = new fs.FormStateLazy(() => [numState]) + + expect(() => getActivated(state as any)).toThrow() + expect(() => getActivated(state as any)).toThrow() + }) +}) diff --git a/src/adapter/index.ts b/src/adapter/index.ts new file mode 100644 index 0000000..a42f0f1 --- /dev/null +++ b/src/adapter/index.ts @@ -0,0 +1,160 @@ +/** + * @file adapter tools + * @description helper methods for adaption between formstate & formstate-x + */ + +import * as fs from 'formstate' +import * as fsx from '..' +import { observable } from 'mobx' + +export type Xify = fsx.ComposibleValidatable> & { + origin: T +} + +/** Convert formstate field / form state into formstate-x state */ +export function xify>(state: T): Xify { + const stateX: Xify = { + origin: state, + get $() { return getValue(state, true) }, + get value() { + // 这里理应有 200ms 的延迟(UI input -> value 的 debounce) + // 考虑有额外的复杂度,且这里不影响逻辑(只影响性能),故不做处理 + return getValue(state, false) + }, + get hasError() { return !!this.error }, + get error() { return getError(state) }, + get validating() { return this._validateStatus === fsx.ValidateStatus.Validating }, + get validated() { return this._validateStatus === fsx.ValidateStatus.Validated }, + validationDisabled: false, + async validate() { + await state.validate() + if (this.hasError) return { hasError: true, error: this.error } + return { hasError: false, value: this.value } + }, + reset() { state.reset() }, + dispose() {}, + get dirty() { return getDirty(state) }, + get _activated() { return getActivated(state) }, + get _validateStatus() { return getValidateStatus(state) } + } + return observable(stateX) +} + +/** Value of `FieldState`. */ +export type ValueOfFieldState = ( + State extends fs.FieldState + ? FieldType + : never +) + +/** Value Array of given Field. */ +// workaround for recursive type reference: https://github.com/Microsoft/TypeScript/issues/3496#issuecomment-128553540 +// not needed for typescript@3.7+: https://github.com/microsoft/TypeScript/pull/33050 +export interface ValueArrayOf extends Array> {} + +/** Value of object-fields. */ +export type ValueOfObjectFields = { + [FieldKey in keyof Fields]: ValueOf +} + +/** Value of array-fields. */ +export type ValueOfArrayFields = ( + Fields extends Array + ? ValueArrayOf + : never +) + +/** Value of fields. */ +export type ValueOfFields = ( + Fields extends { [key: string]: fs.ComposibleValidatable } + ? ValueOfObjectFields + : ValueOfArrayFields +) + +/** Value of state (`FormState` or `FieldState`) */ +export type ValueOf = ( + State extends fs.FormState + ? ValueOfFields + : ValueOfFieldState +) + +function getValueOfForm>(state: T, safe: boolean): ValueOf { + const mode = state['mode'] + if (mode === 'array') { + return state.$.map( + (field: any) => getValue(field, safe) + ) + } + if (mode === 'object') { + const fields = state.$ + return Object.keys(fields).reduce( + (value, key) => ({ + ...value, + [key]: getValue(fields[key], safe) + }), + {} + ) as any + } + throw new Error(`Unsupported mode: ${mode}`) +} + +export function getValue>(state: T, safe: boolean): ValueOf { + if (state instanceof fs.FieldState) return safe ? state.$ : state.value + if (state instanceof fs.FormState) return getValueOfForm(state, safe) + throw new Error(`Expecting ComposibleValidatable value, while got ${typeof state}`) +} + +function getValidateStatusOfField(state: fs.FieldState): fsx.ValidateStatus { + if (state.hasBeenValidated) return fsx.ValidateStatus.Validated + if (state.validating) return fsx.ValidateStatus.Validating + return fsx.ValidateStatus.NotValidated +} + +function getValidateStatusOfForm(state: fs.FormState): fsx.ValidateStatus { + const fields = state['getValues']() + if (fields.every(field => getValidateStatus(field) === fsx.ValidateStatus.NotValidated)) { + return fsx.ValidateStatus.NotValidated + } + if (fields.every(field => getValidateStatus(field) === fsx.ValidateStatus.Validated)) { + return fsx.ValidateStatus.Validated + } + return fsx.ValidateStatus.Validating +} + +export function getValidateStatus(state: fs.ComposibleValidatable): fsx.ValidateStatus { + if (state instanceof fs.FieldState) return getValidateStatusOfField(state) + if (state instanceof fs.FormState) return getValidateStatusOfForm(state) + throw new Error(`Expecting ComposibleValidatable value, while got ${typeof state}`) +} + +function getDirtyOfField(state: fs.FieldState): boolean { + return !!state.dirty +} + +function getDirtyOfForm(state: fs.FormState): boolean { + return state['getValues']().some(field => getDirty(field)) +} + +export function getDirty(state: fs.ComposibleValidatable): boolean { + if (state instanceof fs.FieldState) return getDirtyOfField(state) + if (state instanceof fs.FormState) return getDirtyOfForm(state) + throw new Error(`Expecting ComposibleValidatable value, while got ${typeof state}`) +} + +function getActivatedOfField(state: fs.FieldState): boolean { + return !!(state['_autoValidationEnabled'] && state.dirty) +} + +function getActivatedOfForm(state: fs.FormState): boolean { + return state['getValues']().some(field => getActivated(field)) +} + +export function getActivated(state: fs.ComposibleValidatable): boolean { + if (state instanceof fs.FieldState) return getActivatedOfField(state) + if (state instanceof fs.FormState) return getActivatedOfForm(state) + throw new Error(`Expecting ComposibleValidatable value, while got ${typeof state}`) +} + +function getError(state: fs.ComposibleValidatable) { + return state.hasError ? (state.error as string) : undefined +}