Merge branch 'effects-ts' into typescript
This commit is contained in:
commit
347d90267e
105
src/Updux.ts
105
src/Updux.ts
@ -4,6 +4,9 @@ import {
|
||||
applyMiddleware,
|
||||
DeepPartial,
|
||||
Action,
|
||||
MiddlewareAPI,
|
||||
AnyAction,
|
||||
Middleware,
|
||||
} from 'redux';
|
||||
import {
|
||||
configureStore,
|
||||
@ -12,10 +15,12 @@ import {
|
||||
ActionCreatorWithoutPayload,
|
||||
ActionCreatorWithPreparedPayload,
|
||||
} from '@reduxjs/toolkit';
|
||||
import { AggregateActions, Dux } from './types.js';
|
||||
import { AggregateActions, AggregateSelectors, Dux } from './types.js';
|
||||
import { buildActions } from './buildActions.js';
|
||||
import { buildInitial, AggregateState } from './initial.js';
|
||||
import { buildReducer, MutationCase } from './reducer.js';
|
||||
import { buildEffectsMiddleware, EffectMiddleware } from './effects.js';
|
||||
import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore.js';
|
||||
|
||||
type MyActionCreator = { type: string } & ((...args: any) => any);
|
||||
|
||||
@ -40,7 +45,7 @@ type ResolveActions<
|
||||
[ActionType in keyof A]: ActionType extends string
|
||||
? ResolveAction<ActionType, A[ActionType]>
|
||||
: never;
|
||||
};
|
||||
};
|
||||
|
||||
export type Mutation<A extends Action<any> = Action<any>, S = any> = (
|
||||
payload: A extends {
|
||||
@ -51,12 +56,20 @@ export type Mutation<A extends Action<any> = Action<any>, S = any> = (
|
||||
action: A,
|
||||
) => (state: S) => S | void;
|
||||
|
||||
type SelectorForState<S> = (state: S) => unknown;
|
||||
type SelectorsForState<S> = {
|
||||
[key: string]: SelectorForState<S>;
|
||||
};
|
||||
|
||||
export default class Updux<
|
||||
T_LocalState = Record<any, any>,
|
||||
T_LocalActions extends {
|
||||
[actionType: string]: any;
|
||||
} = {},
|
||||
T_Subduxes extends Record<string, Dux> = {},
|
||||
T_LocalSelectors extends SelectorsForState<
|
||||
AggregateState<T_LocalState, T_Subduxes>
|
||||
> = {},
|
||||
> {
|
||||
#localInitial: T_LocalState;
|
||||
#localActions: T_LocalActions;
|
||||
@ -70,11 +83,20 @@ export default class Updux<
|
||||
|
||||
#initial: AggregateState<T_LocalState, T_Subduxes>;
|
||||
|
||||
#localSelectors: Record<
|
||||
string,
|
||||
(state: AggregateState<T_LocalState, T_Subduxes>) => any
|
||||
>;
|
||||
#selectors: any;
|
||||
|
||||
#localEffects: Middleware[] = [];
|
||||
|
||||
constructor(
|
||||
config: Partial<{
|
||||
initial: T_LocalState;
|
||||
actions: T_LocalActions;
|
||||
subduxes: T_Subduxes;
|
||||
selectors: T_LocalSelectors;
|
||||
}>,
|
||||
) {
|
||||
// TODO check that we can't alter the initial after the fact
|
||||
@ -85,6 +107,17 @@ export default class Updux<
|
||||
this.#actions = buildActions(this.#localActions, this.#subduxes);
|
||||
|
||||
this.#initial = buildInitial(this.#localInitial, this.#subduxes);
|
||||
this.#localSelectors = config.selectors;
|
||||
|
||||
const basedSelectors = R.mergeAll(
|
||||
Object.entries(this.#subduxes)
|
||||
.filter(([slice, { selectors }]) => selectors)
|
||||
.map(([slice, { selectors }]) =>
|
||||
R.mapValues(selectors, (s) => (state) => s(state[slice])),
|
||||
),
|
||||
);
|
||||
|
||||
this.#selectors = R.merge(basedSelectors, this.#localSelectors);
|
||||
}
|
||||
|
||||
get actions() {
|
||||
@ -96,18 +129,69 @@ export default class Updux<
|
||||
return this.#initial;
|
||||
}
|
||||
|
||||
get effects() {
|
||||
return [
|
||||
...this.#localEffects,
|
||||
...Object.entries(this.#subduxes).flatMap(
|
||||
([slice, { effects }]) => {
|
||||
if (!effects) return [];
|
||||
return effects.map(effect => (api) => effect({
|
||||
...api,
|
||||
getState: () => api.getState()[slice],
|
||||
}))
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
createStore(
|
||||
options: Partial<{
|
||||
initial: T_LocalState;
|
||||
}> = {},
|
||||
) {
|
||||
const preloadedState: any = options.initial ?? this.initial;
|
||||
|
||||
const effects = buildEffectsMiddleware(
|
||||
this.effects,
|
||||
this.actions,
|
||||
this.selectors,
|
||||
);
|
||||
|
||||
|
||||
const store = configureStore({
|
||||
reducer: ((state) => state) as Reducer<T_LocalState, any>,
|
||||
reducer: ((state) => state) as Reducer<
|
||||
AggregateState<T_LocalState, T_Subduxes>,
|
||||
AnyAction
|
||||
>,
|
||||
preloadedState,
|
||||
middleware: [effects],
|
||||
});
|
||||
|
||||
return store;
|
||||
const dispatch: any = store.dispatch;
|
||||
for (const a in this.actions) {
|
||||
dispatch[a] = (...args) => {
|
||||
const action = (this.actions as any)[a](...args);
|
||||
dispatch(action);
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
return store as ToolkitStore<
|
||||
AggregateState<T_LocalState, T_Subduxes>
|
||||
> & {
|
||||
dispatch: AggregateActions<
|
||||
ResolveActions<T_LocalActions>,
|
||||
T_Subduxes
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
get selectors(): AggregateSelectors<
|
||||
T_LocalSelectors,
|
||||
T_Subduxes,
|
||||
AggregateState<T_LocalState, T_Subduxes>
|
||||
> {
|
||||
return this.#selectors as any;
|
||||
}
|
||||
|
||||
// TODO memoize this sucker
|
||||
@ -159,4 +243,17 @@ export default class Updux<
|
||||
) {
|
||||
this.#defaultMutation = { mutation, terminal };
|
||||
}
|
||||
|
||||
addEffect(effect: EffectMiddleware) {
|
||||
this.#localEffects.push(effect);
|
||||
}
|
||||
|
||||
get effectsMiddleware() {
|
||||
return buildEffectsMiddleware(
|
||||
this.effects,
|
||||
this.actions,
|
||||
this.selectors,
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -1,26 +0,0 @@
|
||||
export function isActionGen(action) {
|
||||
return typeof action === 'function' && action.type;
|
||||
}
|
||||
|
||||
export function action(type, payloadFunction, transformer) {
|
||||
let generator = function (...payloadArg) {
|
||||
const result = { type };
|
||||
|
||||
if (payloadFunction) {
|
||||
result.payload = payloadFunction(...payloadArg);
|
||||
} else {
|
||||
if (payloadArg[0] !== undefined) result.payload = payloadArg[0];
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
if (transformer) {
|
||||
const orig = generator;
|
||||
generator = (...args) => transformer(orig(...args), args);
|
||||
}
|
||||
|
||||
generator.type = type;
|
||||
|
||||
return generator;
|
||||
}
|
110
src/effects.test.ts
Normal file
110
src/effects.test.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { buildEffectsMiddleware } from './effects.js';
|
||||
import Updux, { createAction } from './index.js';
|
||||
|
||||
test('buildEffectsMiddleware', () => {
|
||||
let seen = 0;
|
||||
|
||||
const mw = buildEffectsMiddleware(
|
||||
[
|
||||
(api) => (next) => (action) => {
|
||||
seen++;
|
||||
expect(api).toHaveProperty('getState');
|
||||
expect(api.getState).toBeTypeOf('function');
|
||||
|
||||
expect(api.getState()).toEqual('the state');
|
||||
|
||||
expect(action).toHaveProperty('type');
|
||||
expect(next).toBeTypeOf('function');
|
||||
|
||||
expect(api).toHaveProperty('actions');
|
||||
expect(api.actions.action1()).toHaveProperty('type', 'action1');
|
||||
|
||||
api.dispatch.action1();
|
||||
|
||||
expect(api.selectors.getFoo(2)).toBe(2);
|
||||
expect(api.getState.getFoo()).toBe('the state');
|
||||
expect(api.getState.getBar(2)).toBe('the state2');
|
||||
|
||||
next(action);
|
||||
},
|
||||
],
|
||||
{
|
||||
action1: createAction('action1'),
|
||||
},
|
||||
{
|
||||
getFoo: (state) => state,
|
||||
getBar: (state) => (i) => state + i,
|
||||
},
|
||||
);
|
||||
|
||||
expect(seen).toEqual(0);
|
||||
const dispatch = vi.fn();
|
||||
mw({ getState: () => 'the state', dispatch })(() => { })({
|
||||
type: 'noop',
|
||||
});
|
||||
expect(seen).toEqual(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'action1' });
|
||||
});
|
||||
|
||||
test('basic', () => {
|
||||
const dux = new Updux({
|
||||
initial: {
|
||||
loaded: true,
|
||||
},
|
||||
actions: {
|
||||
foo: 0,
|
||||
},
|
||||
});
|
||||
|
||||
let seen = 0;
|
||||
dux.addEffect((api) => (next) => (action) => {
|
||||
seen++;
|
||||
expect(api).toHaveProperty('getState');
|
||||
expect(api.getState()).toHaveProperty('loaded');
|
||||
expect(action).toHaveProperty('type');
|
||||
expect(next).toBeTypeOf('function');
|
||||
next(action);
|
||||
});
|
||||
|
||||
const store = dux.createStore();
|
||||
|
||||
expect(seen).toEqual(0);
|
||||
|
||||
store.dispatch.foo();
|
||||
|
||||
expect(seen).toEqual(1);
|
||||
});
|
||||
|
||||
test('subdux', () => {
|
||||
const bar = new Updux({
|
||||
initial: 'bar state',
|
||||
actions: { foo: 0 },
|
||||
});
|
||||
|
||||
let seen = 0;
|
||||
bar.addEffect((api) => next => action => {
|
||||
seen++;
|
||||
expect(api.getState()).toBe('bar state');
|
||||
next(action);
|
||||
});
|
||||
|
||||
const dux = new Updux({
|
||||
initial: {
|
||||
loaded: true,
|
||||
},
|
||||
subduxes: {
|
||||
bar
|
||||
},
|
||||
});
|
||||
|
||||
const store = dux.createStore();
|
||||
|
||||
expect(seen).toEqual(0);
|
||||
|
||||
store.dispatch.foo();
|
||||
|
||||
expect(seen).toEqual(1);
|
||||
});
|
||||
|
||||
// TODO subdux effects
|
||||
// TODO allow to subscribe / unsubscribe effects?
|
47
src/effects.ts
Normal file
47
src/effects.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { AnyAction } from '@reduxjs/toolkit';
|
||||
import { MiddlewareAPI } from '@reduxjs/toolkit';
|
||||
import { Dispatch } from '@reduxjs/toolkit';
|
||||
|
||||
export interface EffectMiddleware<S = any, D extends Dispatch = Dispatch> {
|
||||
(api: MiddlewareAPI<D, S>): (
|
||||
next: Dispatch<AnyAction>,
|
||||
) => (action: AnyAction) => any;
|
||||
}
|
||||
|
||||
const composeMw = (mws) => (api) => (originalNext) =>
|
||||
mws.reduceRight((next, mw) => mw(api)(next), originalNext);
|
||||
|
||||
export function buildEffectsMiddleware(
|
||||
effects = [],
|
||||
actions = {},
|
||||
selectors = {},
|
||||
) {
|
||||
return ({
|
||||
getState: originalGetState,
|
||||
dispatch: originalDispatch,
|
||||
...rest
|
||||
}) => {
|
||||
const dispatch = (action) => originalDispatch(action);
|
||||
|
||||
for (const a in actions) {
|
||||
dispatch[a] = (...args) => dispatch(actions[a](...args));
|
||||
}
|
||||
|
||||
const getState = () => originalGetState();
|
||||
for (const s in selectors) {
|
||||
getState[s] = (...args) => {
|
||||
let result = selectors[s](originalGetState());
|
||||
if (typeof result === 'function') return result(...args);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
let mws = effects.map((e) =>
|
||||
e({ getState, dispatch, actions, selectors, ...rest }),
|
||||
);
|
||||
|
||||
return (originalNext) => {
|
||||
return mws.reduceRight((next, mw) => mw(next), originalNext);
|
||||
};
|
||||
};
|
||||
}
|
32
src/selectors.test.ts
Normal file
32
src/selectors.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { test, expect } from 'vitest';
|
||||
|
||||
import Updux, { createAction } from './index.js';
|
||||
|
||||
test('basic selectors', () => {
|
||||
type State = { x: number };
|
||||
|
||||
const foo = new Updux({
|
||||
initial: {
|
||||
x: 1,
|
||||
},
|
||||
selectors: {
|
||||
getX: ({ x }: State) => x,
|
||||
},
|
||||
subduxes: {
|
||||
bar: new Updux({
|
||||
initial: { y: 2 },
|
||||
selectors: {
|
||||
getY: ({ y }: { y: number }) => y,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const sample = {
|
||||
x: 4,
|
||||
bar: { y: 3 },
|
||||
};
|
||||
|
||||
expect(foo.selectors.getY(sample)).toBe(3);
|
||||
expect(foo.selectors.getX(sample)).toBe(4);
|
||||
});
|
@ -1,43 +0,0 @@
|
||||
import R from 'remeda';
|
||||
|
||||
export function buildSelectors(
|
||||
localSelectors,
|
||||
findSelectors = {},
|
||||
subduxes = {},
|
||||
) {
|
||||
const subSelectors = Object.entries(subduxes).map(
|
||||
([slice, { selectors }]) => {
|
||||
if (!selectors) return {};
|
||||
if (slice === '*') return {};
|
||||
|
||||
return R.mapValues(
|
||||
selectors,
|
||||
(func) => (state) => func(state[slice]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
let splat = {};
|
||||
|
||||
for (const name in findSelectors) {
|
||||
splat[name] =
|
||||
(mainState) =>
|
||||
(...args) => {
|
||||
const state = findSelectors[name](mainState)(...args);
|
||||
|
||||
return R.merge(
|
||||
{ state },
|
||||
R.mapValues(
|
||||
subduxes['*']?.selectors ?? {},
|
||||
(selector) =>
|
||||
(...args) => {
|
||||
let value = selector(state);
|
||||
if (typeof value !== 'function') return value;
|
||||
return value(...args);
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
return R.mergeAll([...subSelectors, localSelectors, splat]);
|
||||
}
|
22
src/types.ts
22
src/types.ts
@ -1,4 +1,4 @@
|
||||
import { Action, ActionCreator, Reducer } from 'redux';
|
||||
import { Action, ActionCreator, Middleware, Reducer } from 'redux';
|
||||
|
||||
export type Dux<
|
||||
STATE = any,
|
||||
@ -6,18 +6,38 @@ export type Dux<
|
||||
> = Partial<{
|
||||
initial: STATE;
|
||||
actions: ACTIONS;
|
||||
selectors: Record<string, (state: STATE) => any>;
|
||||
reducer: (
|
||||
state: STATE,
|
||||
action: ReturnType<ACTIONS[keyof ACTIONS]>,
|
||||
) => STATE;
|
||||
effects: Middleware[];
|
||||
}>;
|
||||
|
||||
type ActionsOf<DUX> = DUX extends { actions: infer A } ? A : {};
|
||||
type SelectorsOf<DUX> = DUX extends { selectors: infer A } ? A : {};
|
||||
|
||||
export type AggregateActions<A, S> = UnionToIntersection<
|
||||
ActionsOf<S[keyof S]> | A
|
||||
>;
|
||||
|
||||
type BaseSelector<F extends (...args: any) => any, STATE> = (
|
||||
state: STATE,
|
||||
) => ReturnType<F>;
|
||||
|
||||
type BaseSelectors<S extends Record<string, any>, STATE> = {
|
||||
[key in keyof S]: BaseSelector<S[key], STATE>;
|
||||
};
|
||||
|
||||
export type AggregateSelectors<
|
||||
S extends Record<string, (...args: any) => any>,
|
||||
SUBS extends Record<string, Dux>,
|
||||
STATE = {},
|
||||
> = BaseSelectors<
|
||||
UnionToIntersection<SelectorsOf<SUBS[keyof SUBS]> | S>,
|
||||
STATE
|
||||
>;
|
||||
|
||||
export type UnionToIntersection<U> = (
|
||||
U extends any ? (k: U) => void : never
|
||||
) extends (k: infer I) => void
|
||||
|
Loading…
Reference in New Issue
Block a user