diff --git a/package.json b/package.json index d78914d..b797bbb 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "devDependencies": { "@size-limit/preset-small-lib": "^7.0.5", "@tsconfig/recommended": "^1.0.1", + "@types/object-path": "^0.11.1", "@types/ramda": "^0.27.61", "dts-cli": "^1.1.3", "size-limit": "^7.0.5", @@ -78,5 +79,8 @@ "bugs": { "url": "https://github.com/JoshuaAmaju/elderform/issues" }, - "homepage": "https://github.com/JoshuaAmaju/elderform#readme" + "homepage": "https://github.com/JoshuaAmaju/elderform#readme", + "dependencies": { + "object-path": "^0.11.8" + } } diff --git a/src/index.ts b/src/index.ts index 04e929d..dc5fbd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import recPath from 'object-path'; import type { Interpreter } from 'xstate'; import { interpret } from 'xstate'; import { @@ -9,12 +10,11 @@ import { States, } from './machine'; import { Validator } from './machine/types'; +import { FlattenKeys, FlattenValues } from './machine/utils'; export * from './machine/types'; export { object, retry } from './tools'; - export type { Context, States, Events }; - export { EventTypes }; declare var __DEV__: boolean; @@ -50,7 +50,9 @@ export type Handler = ( HandleActions; export type Handlers = { - [K in keyof T]: Handler; + [K in FlattenKeys]: K extends keyof T + ? Handler : T[K], Es> + : Handler; }; type Generate = (ctx: Context) => Handlers; @@ -98,10 +100,7 @@ export type Service = { setField: (name: K, value: T[K]) => void; setFieldWithValidate: (name: K, value: T[K]) => void; subscribe: ( - fn: ( - val: SubscriptionValue, - handlers: { [K in keyof T]: Handler } - ) => void + fn: (val: SubscriptionValue, handlers: Handlers) => void ) => () => void; __generate: Generate; __service: Interpreter< @@ -159,9 +158,9 @@ export const createForm = ({ const entries = Object.keys(actors).map((k) => { const id = k as keyof T; - const value = values[id]; const error = errors.get(id); const state = states[id] as any; + const value = recPath.get(values, k); const handler: Handler = { state, diff --git a/src/machine/index.ts b/src/machine/index.ts index eb91199..2fb5161 100644 --- a/src/machine/index.ts +++ b/src/machine/index.ts @@ -3,6 +3,8 @@ import { choose, pure } from 'xstate/lib/actions'; import { Validator } from '..'; import { actor } from './actor'; import { Schema } from './types'; +import { flatten } from './utils'; +import recPath from 'object-path'; declare var __DEV__: boolean; @@ -100,6 +102,21 @@ const onChangeWithValidateActions = [ ), ] as any; +const validateActions = [ + 'removeError', + 'setActorIdle', + send( + ({ values }: any, { id }: any) => ({ + values, + type: 'VALIDATE', + value: recPath.get(values, id), + }), + { + to: (_, { id }) => id, + } + ), +] as any; + export const machine = () => { return createMachine< Context, @@ -135,7 +152,7 @@ export const machine = () => { actions: onChangeActions, }, - [EventTypes.Validate]: { + [EventTypes.ChangeWithValidate]: { target: 'idle', cond: 'hasSchema', actions: onChangeWithValidateActions, @@ -251,7 +268,7 @@ export const machine = () => { [EventTypes.Validate]: { cond: 'hasSchema', - actions: onChangeWithValidateActions, + actions: validateActions, }, [EventTypes.ChangeWithValidate]: { @@ -273,7 +290,7 @@ export const machine = () => { return Object.keys(actors) .filter((key) => !__ignore.has(key as keyof T)) .map((key) => { - const value = values[key as keyof T]; + const value = recPath.get(values, key); return send( { value, values, type: 'VALIDATE' }, { to: key as string } @@ -403,10 +420,9 @@ export const machine = () => { setInitialStates: assign({ states: ({ schema }) => { - const entries = Object.keys(schema as Schema).map((key) => [ - key, - 'idle', - ]); + const flattened = flatten(schema as Schema); + + const entries = Object.keys(flattened).map((key) => [key, 'idle']); return Object.fromEntries(entries); }, }), @@ -415,11 +431,13 @@ export const machine = () => { actors: ({ schema }) => { const shape = schema as Schema; - const entries = Object.keys(shape).map((key) => { + const flattened = flatten(shape); + + const entries = Object.keys(flattened).map((key) => { const act = spawn( actor({ id: key as string, - validator: shape[key], + validator: flattened[key], }), key as string ); @@ -433,7 +451,8 @@ export const machine = () => { setValue: assign({ values: ({ values }, { id, value }: any) => { - return { ...values, [id]: value }; + recPath.set(values, id, value); + return values; }, }), diff --git a/src/machine/types.ts b/src/machine/types.ts index 74720c5..d175438 100644 --- a/src/machine/types.ts +++ b/src/machine/types.ts @@ -1,4 +1,6 @@ -export type Schema = { [K in keyof T]: Validator }; +export type Schema = { + [K in keyof T]: Schema | Validator; +}; export type Validator = ( value: T, diff --git a/src/machine/utils.ts b/src/machine/utils.ts new file mode 100644 index 0000000..874d618 --- /dev/null +++ b/src/machine/utils.ts @@ -0,0 +1,50 @@ +import { Validator } from './types'; + +export type FlattenKeys = { + [K in keyof T & (string | number)]: RecursiveKeyOfHandleValue; +}[keyof T & (string | number)]; + +type RecursiveKeyOfInner = { + [K in keyof T & (string | number)]: RecursiveKeyOfHandleValue; +}[keyof T & (string | number)]; + +type RecursiveKeyOfHandleValue< + TValue, + Text extends string +> = TValue extends any[] + ? Text + : TValue extends object + ? Text | `${Text}${RecursiveKeyOfInner}` + : Text; + +export type FlattenValues = { + [K in keyof T & (string | number)]: RecursiveValueOfHandleValue; +}[keyof T & (string | number)]; + +type RecursiveValueOfInner = { + [K in keyof T & (string | number)]: RecursiveValueOfHandleValue; +}[keyof T & (string | number)]; + +type RecursiveValueOfHandleValue = TValue extends object + ? RecursiveValueOfInner + : TValue; + +export const flatten = ( + obj: T, + roots: (keyof T)[] = [], + sep = '.' +): { [K in keyof T]: Validator } => { + return Object.keys(obj).reduce((accumulator, k) => { + const key = k as keyof T; + const value = obj[key]; + + return { + ...accumulator, + ...(Object.prototype.toString.call(value) === '[object Object]' + ? // keep working if value is an object + flatten(value as any, roots.concat([key]), sep) + : // include current prop and value and prefix prop with the roots + { [roots.concat([key]).join(sep)]: value }), + }; + }, {} as any); +}; diff --git a/test/index.test.ts b/test/index.test.ts index 30824ec..560dea2 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -424,3 +424,106 @@ describe('dynamic schema', () => { }); }); }); + +describe('nested schemas', () => { + const schema = object({ + name: (v: any) => z.string().parse(v), + address: object({ + line: (v: any) => z.string().parse(v), + city: (v: any) => z.string().parse(v), + state: (v: any) => z.string().parse(v), + }), + }); + + type Form = Infer; + + let service: Interpreter< + Context, + any, + Events, + States + > | null; + + beforeEach(() => { + const def = machine(); + + service = interpret( + def.withContext({ + ...def.context, + schema, + values: { + name: 'Jane', + address: { + line: 'No 4', + city: 'Alausa', + state: 'Lagos', + }, + }, + }) + ).start(); + }); + + afterEach(() => { + service?.stop(); + service = null; + }); + + it('should support nested values', (done) => { + service?.onTransition((state) => { + const { values } = state.context; + + expect(values).toMatchObject({ + name: 'Jane', + address: { + line: 'No 4', + city: 'Alausa', + state: 'Lagos', + }, + }); + + done(); + }); + }); + + it('should support setting value using dot notation', (done) => { + service?.onChange(({ values }) => { + expect(values).toMatchObject({ + name: 'Jane', + address: { line: 'No 15', city: 'Alausa', state: 'Lagos' }, + }); + + done(); + }); + + service?.send({ + type: EventTypes.Change, + id: 'address.line' as any, + value: 'No 15', + }); + }); + + it('should access field state and error using dot notation', (done) => { + const dotKey = 'address.line' as keyof Form; + + service?.onTransition(({ event, context }) => { + expect(context.errors.has(dotKey)).toBe(false); + + switch (event.type) { + case 'VALIDATING': + expect(context.states[dotKey]).toBe('validating'); + break; + + case 'SUCCESS': + expect(context.states[dotKey]).toBe('success'); + done(); + break; + + default: + expect(context.states[dotKey]).toBe('idle'); + break; + } + }); + + service?.send({ id: dotKey, type: EventTypes.Validate }); + }); +});