-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(xstate-mutative): implement xstate-mutative
- Loading branch information
Showing
2 changed files
with
336 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<O extends PatchesOptions, F extends boolean> = Pick< | ||
Options<O, F>, | ||
Exclude<keyof Options<O, F>, '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<Draft<TContext>, 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<TContext, TExpressionEvent, TParams, TEvent, TActor>, | ||
mutativeOptions?: MutativeOptions<false, TAutoFreeze> | ||
) { | ||
return xstateAssign<TContext, TExpressionEvent, TParams, TEvent, TActor>( | ||
({ context, ...rest }, params) => { | ||
return create( | ||
context, | ||
(draft) => | ||
void recipe( | ||
{ | ||
context: draft, | ||
...rest, | ||
} as any, | ||
params | ||
), | ||
mutativeOptions | ||
) as LowInfer<TContext>; | ||
} | ||
); | ||
} | ||
|
||
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, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<FormContext, NameUpdateEvent, FormEvent>( | ||
'UPDATE_NAME', | ||
({ context, event }) => { | ||
context.name = event.input; | ||
} | ||
); | ||
|
||
const ageUpdater = createUpdater<FormContext, AgeUpdateEvent, FormEvent>( | ||
'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' }); | ||
}); |