From 7dac5dc78eb92398caf3fcd50e1f81d75f6f7e7c Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 20 Sep 2024 02:24:54 +0800 Subject: [PATCH] feat(xstate-mutative): implement xstate-mutative --- src/index.ts | 110 ++++++++++++++++++++- test/index.test.ts | 232 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 336 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index c68b57a..df56364 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,107 @@ -export const add = (a: number, b: number) => { - return a + b; -}; +import { + create, + type Draft, + type Options, + type PatchesOptions, +} from 'mutative'; +import { + type AssignArgs, + type EventObject, + type LowInfer, + type MachineContext, + type ParameterizedObject, + type ProvidedActor, + assign as xstateAssign, +} from 'xstate'; + +export type MutativeOptions = Pick< + Options, + Exclude, 'enablePatches'> +>; + +export { mutativeAssign as assign }; + +export type MutativeAssigner< + TContext extends MachineContext, + TExpressionEvent extends EventObject, + TParams extends ParameterizedObject['params'] | undefined, + TEvent extends EventObject, + TActor extends ProvidedActor +> = ( + args: AssignArgs, TExpressionEvent, TEvent, TActor>, + params: TParams +) => void; + +function mutativeAssign< + TContext extends MachineContext, + TExpressionEvent extends EventObject = EventObject, + TParams extends ParameterizedObject['params'] | undefined = + | ParameterizedObject['params'] + | undefined, + TEvent extends EventObject = EventObject, + TActor extends ProvidedActor = ProvidedActor, + TAutoFreeze extends boolean = false +>( + recipe: MutativeAssigner, + mutativeOptions?: MutativeOptions +) { + return xstateAssign( + ({ context, ...rest }, params) => { + return create( + context, + (draft) => + void recipe( + { + context: draft, + ...rest, + } as any, + params + ), + mutativeOptions + ) as LowInfer; + } + ); +} + +export interface MutativeUpdateEvent< + TType extends string = string, + TInput = unknown +> { + type: TType; + input: TInput; +} + +export function createUpdater< + TContext extends MachineContext, + TExpressionEvent extends MutativeUpdateEvent, + TEvent extends EventObject, + TActor extends ProvidedActor = ProvidedActor +>( + type: TExpressionEvent['type'], + recipe: MutativeAssigner< + TContext, + TExpressionEvent, + ParameterizedObject['params'] | undefined, + TEvent, + TActor + > +) { + const update = (input: TExpressionEvent['input']): TExpressionEvent => { + return { + type, + input, + } as TExpressionEvent; + }; + + return { + update, + action: mutativeAssign< + TContext, + TExpressionEvent, + ParameterizedObject['params'] | undefined, // TODO: not sure if this is correct + TEvent, + TActor + >(recipe), + type, + }; +} diff --git a/test/index.test.ts b/test/index.test.ts index 71445fc..f3bc2cb 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,5 +1,231 @@ -import { add } from '../src/index'; +import { createMachine, createActor } from 'xstate'; +import { assign, createUpdater, MutativeUpdateEvent } from '../src'; -test('add', () => { - expect(add(1, 2)).toBe(3); +it('should update the context without modifying previous contexts', () => { + const context = { + count: 0, + }; + const countMachine = createMachine({ + types: {} as { context: typeof context }, + id: 'count', + context, + initial: 'active', + states: { + active: { + on: { + INC: { + actions: assign(({ context }) => context.count++), + }, + }, + }, + }, + }); + + const actorRef = createActor(countMachine).start(); + expect(actorRef.getSnapshot().context).toEqual({ count: 0 }); + + actorRef.send({ type: 'INC' }); + expect(actorRef.getSnapshot().context).toEqual({ count: 1 }); + + actorRef.send({ type: 'INC' }); + expect(actorRef.getSnapshot().context).toEqual({ count: 2 }); +}); + +it('should perform multiple updates correctly', () => { + const context = { + count: 0, + }; + const countMachine = createMachine( + { + types: {} as { context: typeof context }, + id: 'count', + context, + initial: 'active', + states: { + active: { + on: { + INC_TWICE: { + actions: ['increment', 'increment'], + }, + }, + }, + }, + }, + { + actions: { + increment: assign(({ context }) => context.count++), + }, + } + ); + + const actorRef = createActor(countMachine).start(); + expect(actorRef.getSnapshot().context).toEqual({ count: 0 }); + + actorRef.send({ type: 'INC_TWICE' }); + expect(actorRef.getSnapshot().context).toEqual({ count: 2 }); +}); + +it('should perform deep updates correctly', () => { + const context = { + foo: { + bar: { + baz: [1, 2, 3], + }, + }, + }; + const countMachine = createMachine( + { + types: {} as { context: typeof context }, + id: 'count', + context, + initial: 'active', + states: { + active: { + on: { + INC_TWICE: { + actions: ['pushBaz', 'pushBaz'], + }, + }, + }, + }, + }, + { + actions: { + pushBaz: assign(({ context }) => context.foo.bar.baz.push(0)), + }, + } + ); + + const actorRef = createActor(countMachine).start(); + expect(actorRef.getSnapshot().context.foo.bar.baz).toEqual([1, 2, 3]); + + actorRef.send({ type: 'INC_TWICE' }); + expect(actorRef.getSnapshot().context.foo.bar.baz).toEqual([1, 2, 3, 0, 0]); +}); + +it('should create updates', () => { + interface MyContext { + foo: { + bar: { + baz: number[]; + }; + }; + } + const context: MyContext = { + foo: { + bar: { + baz: [1, 2, 3], + }, + }, + }; + + type MyEvents = + | MutativeUpdateEvent<'UPDATE_BAZ', number> + | MutativeUpdateEvent<'OTHER', string>; + + const bazUpdater = createUpdater< + typeof context, + MutativeUpdateEvent<'UPDATE_BAZ', number>, + MyEvents + >('UPDATE_BAZ', ({ context, event }) => { + context.foo.bar.baz.push(event.input); + }); + + const countMachine = createMachine({ + types: { + context: {} as MyContext, + events: {} as MyEvents, + }, + id: 'count', + context, + initial: 'active', + states: { + active: { + on: { + [bazUpdater.type]: { + actions: bazUpdater.action, + }, + }, + }, + }, + }); + + const actorRef = createActor(countMachine).start(); + expect(actorRef.getSnapshot().context.foo.bar.baz).toEqual([1, 2, 3]); + + actorRef.send(bazUpdater.update(4)); + expect(actorRef.getSnapshot().context.foo.bar.baz).toEqual([1, 2, 3, 4]); +}); + +it('should create updates (form example)', (done) => { + interface FormContext { + name: string; + age: number | undefined; + } + + type NameUpdateEvent = MutativeUpdateEvent<'UPDATE_NAME', string>; + type AgeUpdateEvent = MutativeUpdateEvent<'UPDATE_AGE', number>; + + type FormEvent = + | NameUpdateEvent + | AgeUpdateEvent + | { + type: 'SUBMIT'; + }; + + const nameUpdater = createUpdater( + 'UPDATE_NAME', + ({ context, event }) => { + context.name = event.input; + } + ); + + const ageUpdater = createUpdater( + 'UPDATE_AGE', + ({ context, event }) => { + context.age = event.input; + } + ); + + const formMachine = createMachine({ + types: {} as { context: FormContext; events: FormEvent }, + initial: 'editing', + context: { + name: '', + age: undefined, + }, + states: { + editing: { + on: { + [nameUpdater.type]: { actions: nameUpdater.action }, + [ageUpdater.type]: { actions: ageUpdater.action }, + SUBMIT: 'submitting', + }, + }, + submitting: { + always: { + target: 'success', + guard: ({ context }) => { + return context.name === 'David' && context.age === 0; + }, + }, + }, + success: { + type: 'final', + }, + }, + }); + + const service = createActor(formMachine); + service.subscribe({ + complete: () => { + done(); + }, + }); + service.start(); + + service.send(nameUpdater.update('David')); + service.send(ageUpdater.update(0)); + + service.send({ type: 'SUBMIT' }); });