Skip to content

Commit

Permalink
feat(xstate-mutative): implement xstate-mutative
Browse files Browse the repository at this point in the history
  • Loading branch information
unadlib committed Sep 19, 2024
1 parent 3d31dd6 commit 7dac5dc
Show file tree
Hide file tree
Showing 2 changed files with 336 additions and 6 deletions.
110 changes: 107 additions & 3 deletions src/index.ts
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,
};
}
232 changes: 229 additions & 3 deletions test/index.test.ts
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' });
});

0 comments on commit 7dac5dc

Please sign in to comment.