Merge branch 'reducer-ts' into typescript

main
Yanick Champoux 2023-03-09 14:24:03 -05:00
commit e55bf7a771
8 changed files with 230 additions and 76 deletions

View File

@ -15,6 +15,7 @@ import {
import { AggregateActions, Dux } from './types.js';
import { buildActions } from './buildActions.js';
import { buildInitial, AggregateState } from './initial.js';
import { buildReducer, MutationCase } from './reducer.js';
type MyActionCreator = { type: string } & ((...args: any) => any);
@ -41,11 +42,14 @@ type ResolveActions<
: never;
};
type Mutation<A extends ActionCreator<any> = ActionCreator<any>, S = any> = (
state: S,
payload: ReturnType<A>['payload'],
action: ReturnType<A>,
) => S | void;
export type Mutation<A extends Action<any> = Action<any>, S = any> = (
payload: A extends {
payload: infer P;
}
? P
: undefined,
action: A,
) => (state: S) => S | void;
export default class Updux<
T_LocalState = Record<any, any>,
@ -56,10 +60,8 @@ export default class Updux<
> {
#localInitial: T_LocalState;
#localActions: T_LocalActions;
#localMutations: Record<
string,
Mutation<ActionCreator<any>, AggregateState<T_LocalState, T_Subduxes>>
> = {};
#localMutations: MutationCase[] = [];
#defaultMutation: Omit<MutationCase, 'matcher'>;
#subduxes: T_Subduxes;
#name: string;
@ -108,11 +110,52 @@ export default class Updux<
return store;
}
// TODO force the actionCreator to be one of the actions?
mutation<A extends ActionCreator<any>>(
actionCreator: A,
// TODO memoize this sucker
get reducer() {
return buildReducer(
this.initial,
this.#localMutations,
this.#defaultMutation,
) as any as (
state: undefined | typeof this.initial,
action: Action,
) => typeof this.initial;
}
// TODO be smarter with the guard?
addMutation<A extends Action<any>>(
matcher: (action: A) => boolean,
mutation: Mutation<A, AggregateState<T_LocalState, T_Subduxes>>,
terminal?: boolean,
);
addMutation<A extends ActionCreator<any>>(
actionCreator: A,
mutation: Mutation<
ReturnType<A>,
AggregateState<T_LocalState, T_Subduxes>
>,
terminal?: boolean,
);
addMutation(matcher, mutation, terminal = false) {
if (typeof matcher === 'function' && matcher.match) {
// matcher, matcher man...
matcher = matcher.match;
}
this.#localMutations.push({
terminal,
matcher,
mutation,
});
}
addDefaultMutation(
mutation: Mutation<
Action<any>,
AggregateState<T_LocalState, T_Subduxes>
>,
terminal = false,
) {
this.#localMutations[(actionCreator as any).type] = mutation;
this.#defaultMutation = { mutation, terminal };
}
}

View File

@ -1,4 +1,3 @@
import { test, expect } from 'vitest';
import schema from 'json-schema-shorthand';
import u from 'updeep';
@ -6,31 +5,6 @@ import { action } from './actions.js';
import { Updux, dux } from './Updux.js';
test('set a mutation', () => {
const dux = new Updux({
initial: {
x: 'potato',
},
actions: {
foo: action('foo', (x) => ({ x })),
bar: action('bar'),
},
});
dux.setMutation(dux.actions.foo, (payload, action) => {
expect(payload).toEqual({ x: 'hello ' });
expect(action).toEqual(dux.actions.foo('hello '));
return u({
x: payload.x + action.type,
});
});
const result = dux.reducer(undefined, dux.actions.foo('hello '));
expect(result).toEqual({
x: 'hello foo',
});
});
test('mutation of a subdux', async () => {
const bar = dux({
actions: {
@ -72,19 +46,3 @@ test('strings and generators', async () => {
expect(foo.actions.d).toBeTypeOf('function');
});
test('splat mutation', () => {
const myDux = new Updux({
initial: [],
actions: { one: null, two: null },
mutations: {
'*': (payload) => (state) => payload ? [...state, payload] : state,
},
});
const store = myDux.createStore();
expect(store.getState()).toEqual([]);
store.dispatch.one(11);
store.dispatch.two(22);
expect(store.getState()).toEqual([11, 22]);
});

55
src/mutations.test.ts Normal file
View File

@ -0,0 +1,55 @@
import { test, expect } from 'vitest';
import Updux from './index.js';
test('set a mutation', () => {
const dux = new Updux({
initial: 'potato',
actions: {
foo: (x) => ({ x }),
bar: 0,
},
});
let didIt = false;
dux.addMutation(dux.actions.foo, (payload, action) => () => {
didIt = true;
expect(payload).toEqual({ x: 'hello ' });
expect(action).toEqual(dux.actions.foo('hello '));
return payload.x + action.type;
});
const result = dux.reducer(undefined, dux.actions.foo('hello '));
expect(didIt).toBeTruthy();
expect(result).toEqual('hello foo');
});
test('catch-all mutation', () => {
const dux = new Updux({
initial: '',
});
dux.addMutation(
() => true,
(payload, action) => () => 'got it',
);
expect(dux.reducer(undefined, { type: 'foo' })).toEqual('got it');
});
test('default mutation', () => {
const dux = new Updux({
initial: '',
actions: {
foo: 0,
},
});
dux.addMutation(dux.actions.foo, () => () => 'got it');
dux.addDefaultMutation((_payload, action) => () => action.type);
expect(dux.reducer(undefined, { type: 'foo' })).toEqual('got it');
expect(dux.reducer(undefined, { type: 'bar' })).toEqual('bar');
});

View File

@ -1,19 +0,0 @@
import { test, expect } from 'vitest';
import { Updux } from './Updux.js';
test('basic reducer', () => {
const dux = new Updux({ initial: { a: 3 } });
expect(dux.reducer).toBeTypeOf('function');
expect(dux.reducer({ a: 1 }, { type: 'foo' })).toMatchObject({ a: 1 }); // noop
});
test('basic upreducer', () => {
const dux = new Updux({ initial: { a: 3 } });
expect(dux.upreducer).toBeTypeOf('function');
expect(dux.upreducer({ type: 'foo' })({ a: 1 })).toMatchObject({ a: 1 }); // noop
});

31
src/reducer.test.ts Normal file
View File

@ -0,0 +1,31 @@
import { test, expect } from 'vitest';
import { buildReducer } from './reducer.js';
import Updux from './Updux.js';
test('buildReducer, initial state', () => {
const reducer = buildReducer({ a: 1 });
expect(reducer(undefined, { type: 'foo' })).toEqual({ a: 1 });
});
test('buildReducer, mutation', () => {
const reducer = buildReducer(1, [
{
matcher: ({ type }) => type === 'inc',
mutation: () => (state) => state + 1,
terminal: false,
},
]);
expect(reducer(undefined, { type: 'foo' })).toEqual(1);
expect(reducer(undefined, { type: 'inc' })).toEqual(2);
});
test.todo('basic reducer', () => {
const dux = new Updux({ initial: { a: 3 } });
expect(dux.reducer).toBeTypeOf('function');
expect(dux.reducer({ a: 1 }, { type: 'foo' })).toMatchObject({ a: 1 }); // noop
});

82
src/reducer.ts Normal file
View File

@ -0,0 +1,82 @@
import { Action, ActionCreator, createAction } from '@reduxjs/toolkit';
import { BaseActionCreator } from '@reduxjs/toolkit/dist/createAction.js';
import * as R from 'remeda';
import { Dux } from './types.js';
import { Mutation } from './Updux.js';
export type MutationCase = {
matcher: (action: Action) => boolean;
mutation: Mutation;
terminal: boolean;
};
export function buildReducer(
initialState: any,
mutations: MutationCase[] = [],
defaultMutation?: Omit<MutationCase, 'matcher'>,
subduxes: Record<string, Dux> = {},
) {
// const subReducers =
// ? R.mapValues(subduxes, R.prop('reducer'));
// TODO matcherMutation
// TODO defaultMutation
//
const reducer = (state = initialState, action: Action) => {
if (!action?.type)
throw new Error('upreducer called with a bad action');
let terminal = false;
let didSomething = false;
mutations
.filter(({ matcher }) => matcher(action))
.forEach(({ mutation, terminal: t }) => {
if (t) terminal = true;
didSomething = true;
//
// TODO wrap mutations in immer
state = mutation((action as any).payload, action)(state);
});
if (!didSomething && defaultMutation) {
if (defaultMutation.terminal) terminal = true;
state = defaultMutation.mutation(
(action as any).payload,
action,
)(state);
}
return state;
};
return reducer;
/*
if (subReducers) {
if (subduxes['*']) {
newState = u.updateIn(
'*',
subduxes['*'].upreducer(action),
newState,
);
} else {
const update = mapValues(subReducers, (upReducer) =>
upReducer(action),
);
newState = u(update, newState);
}
}
const a = mutations[action.type] || mutations['+'];
if (!a) return newState;
return a(action.payload, action)(newState);
};
return wrapper ? wrapper(upreducer) : upreducer;
*/
}

View File

@ -54,7 +54,7 @@ test('mutation', () => {
initial: { nextId: 0, todos: [] as Todo[] },
});
dux.mutation(addTodo, (state, description) => {
dux.addMutation(addTodo, (description) => (state) => {
state.todos.unshift({ description, id: state.nextId, done: false });
state.nextId++;
});

View File

@ -1,4 +1,4 @@
import { Action, ActionCreator } from 'redux';
import { Action, ActionCreator, Reducer } from 'redux';
export type Dux<
STATE = any,
@ -6,6 +6,10 @@ export type Dux<
> = Partial<{
initial: STATE;
actions: ACTIONS;
reducer: (
state: STATE,
action: ReturnType<ACTIONS[keyof ACTIONS]>,
) => STATE;
}>;
type ActionsOf<DUX> = DUX extends { actions: infer A } ? A : {};