diff --git a/src/Updux.ts b/src/Updux.ts index d35b81c..8045e73 100644 --- a/src/Updux.ts +++ b/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); @@ -26,10 +31,10 @@ type ResolveAction< ? ActionArg : ActionArg extends (...args: any) => any ? ActionCreatorWithPreparedPayload< - Parameters, - ReturnType, - ActionType - > + Parameters, + ReturnType, + ActionType + > : ActionCreatorWithoutPayload; type ResolveActions< @@ -37,10 +42,10 @@ type ResolveActions< [key: string]: any; }, > = { - [ActionType in keyof A]: ActionType extends string + [ActionType in keyof A]: ActionType extends string ? ResolveAction : never; -}; + }; export type Mutation = Action, S = any> = ( payload: A extends { @@ -51,12 +56,20 @@ export type Mutation = Action, S = any> = ( action: A, ) => (state: S) => S | void; +type SelectorForState = (state: S) => unknown; +type SelectorsForState = { + [key: string]: SelectorForState; +}; + export default class Updux< T_LocalState = Record, T_LocalActions extends { [actionType: string]: any; } = {}, T_Subduxes extends Record = {}, + T_LocalSelectors extends SelectorsForState< + AggregateState + > = {}, > { #localInitial: T_LocalState; #localActions: T_LocalActions; @@ -70,11 +83,20 @@ export default class Updux< #initial: AggregateState; + #localSelectors: Record< + string, + (state: AggregateState) => 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, + reducer: ((state) => state) as Reducer< + AggregateState, + 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 + > & { + dispatch: AggregateActions< + ResolveActions, + T_Subduxes + >; + }; + } + + get selectors(): AggregateSelectors< + T_LocalSelectors, + T_Subduxes, + AggregateState + > { + 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, + ); + + } } diff --git a/src/actions.todo b/src/actions.todo deleted file mode 100644 index 51ad508..0000000 --- a/src/actions.todo +++ /dev/null @@ -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; -} diff --git a/src/effects.test.ts b/src/effects.test.ts new file mode 100644 index 0000000..f500cae --- /dev/null +++ b/src/effects.test.ts @@ -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? diff --git a/src/effects.ts b/src/effects.ts new file mode 100644 index 0000000..489e715 --- /dev/null +++ b/src/effects.ts @@ -0,0 +1,47 @@ +import { AnyAction } from '@reduxjs/toolkit'; +import { MiddlewareAPI } from '@reduxjs/toolkit'; +import { Dispatch } from '@reduxjs/toolkit'; + +export interface EffectMiddleware { + (api: MiddlewareAPI): ( + next: Dispatch, + ) => (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); + }; + }; +} diff --git a/src/selectors.test.ts b/src/selectors.test.ts new file mode 100644 index 0000000..11fe428 --- /dev/null +++ b/src/selectors.test.ts @@ -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); +}); diff --git a/src/selectors.todo b/src/selectors.todo deleted file mode 100644 index e90b4e9..0000000 --- a/src/selectors.todo +++ /dev/null @@ -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]); -} diff --git a/src/types.ts b/src/types.ts index fb4a0a6..ee58fa3 100644 --- a/src/types.ts +++ b/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 any>; reducer: ( state: STATE, action: ReturnType, ) => STATE; + effects: Middleware[]; }>; type ActionsOf = DUX extends { actions: infer A } ? A : {}; +type SelectorsOf = DUX extends { selectors: infer A } ? A : {}; export type AggregateActions = UnionToIntersection< ActionsOf | A >; +type BaseSelector any, STATE> = ( + state: STATE, +) => ReturnType; + +type BaseSelectors, STATE> = { + [key in keyof S]: BaseSelector; +}; + +export type AggregateSelectors< + S extends Record any>, + SUBS extends Record, + STATE = {}, +> = BaseSelectors< + UnionToIntersection | S>, + STATE +>; + export type UnionToIntersection = ( U extends any ? (k: U) => void : never ) extends (k: infer I) => void