diff --git a/README.md b/README.md index ffbc064..fc26eaf 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,11 @@ - # What's Updux? So, I'm a fan of [Redux](https://redux.js.org). Two days ago I discovered -[rematch](https://rematch.github.io/rematch) alonside a few other frameworks built atop Redux. +[rematch](https://rematch.github.io/rematch) alonside a few other frameworks built atop Redux. -It has a couple of pretty good ideas that removes some of the -boilerplate. Keeping mutations and asynchronous effects close to the -reducer definition? Nice. Automatically infering the +It has a couple of pretty good ideas that removes some of the +boilerplate. Keeping mutations and asynchronous effects close to the +reducer definition? Nice. Automatically infering the actions from the said mutations and effects? Genius! But it also enforces a flat hierarchy of reducers -- where @@ -16,17 +15,16 @@ is the fun in that? And I'm also having a strong love for All that to say, say hello to `Updux`. Heavily inspired by `rematch`, but twisted to work with `updeep` and to fit my peculiar needs. It offers features such as -* Mimic the way VueX has mutations (reducer reactions to specific actions) and +- Mimic the way VueX has mutations (reducer reactions to specific actions) and effects (middleware reacting to actions that can be asynchronous and/or have side-effects), so everything pertaining to a store are all defined in the space place. -* Automatically gather all actions used by the updux's effects and mutations, +- Automatically gather all actions used by the updux's effects and mutations, and makes then accessible as attributes to the `dispatch` object of the store. -* Mutations have a signature that is friendly to Updux and Immer. -* Also, the mutation signature auto-unwrap the payload of the actions for you. -* TypeScript types. - +- Mutations have a signature that is friendly to Updux and Immer. +- Also, the mutation signature auto-unwrap the payload of the actions for you. +- TypeScript types. Fair warning: this package is still very new, probably very buggy, definitively very badly documented, and very subject to changes. Caveat @@ -45,7 +43,7 @@ const { actions, middleware, createStore, -} = new Updux({ +} = new Updux({ initial: { counter: 0, }, @@ -62,9 +60,9 @@ const { }; }, actions: { - customAction: ( someArg ) => ({ - type: "custom", - payload: { someProp: someArg } + customAction: ( someArg ) => ({ + type: "custom", + payload: { someProp: someArg } }), }, @@ -78,6 +76,8 @@ store.dispatch.inc(3); # Description Full documentation can be [found here](https://yanick.github.io/updux/). +Right now the best way to understand the whole thing is to go +through the [tutorial](https://yanick.github.io/updux/#/tutorial) ## Exporting upduxes @@ -94,7 +94,6 @@ const updux = new Updux({ ... }); export default updux; ``` - Then you can use them as subduxes like this: ``` @@ -137,12 +136,12 @@ const todo = new Updux({ const todos = new Updux({ initial: [] }); -todos.addMutation( - todo.actions.review, - (_,action) => state => state.map( todo.upreducer(action) ) +todos.addMutation( + todo.actions.review, + (_,action) => state => state.map( todo.upreducer(action) ) ); todos.addMutation( - todo.actions.done, + todo.actions.done, (id,action) => u.map(u.if(u.is('id',id), todo.upreducer(action))), ); @@ -165,14 +164,14 @@ const todos = new Updux({ }); todos.addMutation( - todo.actions.done, - (id,action) => u.map(u.if(u.is('id',id), todo.upreducer(action))), + todo.actions.done, + (id,action) => u.map(u.if(u.is('id',id), todo.upreducer(action))), true ); ``` The advantages being that the actions/mutations/effects of the subdux will be -imported by the root updux as usual, and all actions that aren't being +imported by the root updux as usual, and all actions that aren't being overridden by a sink mutation will trickle down automatically. ## Usage with Immer @@ -188,15 +187,14 @@ import Updux from 'updux'; const updux = new Updux({ initial: { counter: 0 }, mutations: { - add: (inc=1) => state => { counter: counter + inc } + add: (inc=1) => state => { counter: counter + inc } } }); - + ``` Converting it to Immer would look like: - ``` import Updux from 'updux'; import { produce } from 'Immer'; @@ -204,16 +202,15 @@ import { produce } from 'Immer'; const updux = new Updux({ initial: { counter: 0 }, mutations: { - add: (inc=1) => produce( draft => draft.counter += inc ) } + add: (inc=1) => produce( draft => draft.counter += inc ) } } }); - + ``` But since typing `produce` over and over is no fun, `groomMutations` can be used to wrap all mutations with it: - ``` import Updux from 'updux'; import { produce } from 'Immer'; @@ -222,11 +219,8 @@ const updux = new Updux({ initial: { counter: 0 }, groomMutations: mutation => (...args) => produce( mutation(...args) ), mutations: { - add: (inc=1) => draft => draft.counter += inc + add: (inc=1) => draft => draft.counter += inc } }); - + ``` - - - diff --git a/docs/tutorial.md b/docs/tutorial.md index 51b7f03..512fdc3 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -1,19 +1,19 @@ # Tutorial -This tutorial walks you through the features of `Updux` using the +This tutorial walks you through the features of `Updux` using the time-honored example of the implementation of Todo list store. -This tutorial assumes that our project is written in TypeScript, and -that we are using [updeep](https://www.npmjs.com/package/updeep) to +This tutorial assumes that our project is written in TypeScript, and +that we are using [updeep](https://www.npmjs.com/package/updeep) to help with immutability and deep merging and [ts-action][] to manage our -actions. This is the recommended setup, but +actions. This is the recommended setup, but neither of those two architecture decisions are mandatory; Updux is equally usable in a pure-JavaScript setting, and `updeep` can easily be substitued with, say, [immer][], [lodash][], or even just plain JavaScript. Eventually, I plan to write a version of this tutorial with all those different configurations. -Also, the code used here is also available in the project repository, in the +Also, the code used here is also available in the project repository, in the `src/tutorial` directory. ## Definition of the state @@ -46,7 +46,7 @@ const todosUpdux = new Updux({ }); ``` -Note that we explicitly cast the initial state as `as TodoStore`. This lets +Note that we explicitly cast the initial state as `as TodoStore`. This lets Updux know what is the store's state. This being said, congrats! You have written your first Updux object. It @@ -56,7 +56,7 @@ initial state will be automatically set: ``` const store = todosUpdux.createStore(); -console.log(store.getState()); +console.log(store.getState()); // { next_id: 1, todos: [] } ``` @@ -71,7 +71,7 @@ const add_todo = action('add_todo', payload() ); const todo_done = action('todo_done', payload() ); ``` -Now, there is a lot of ways to add actions to a Updux object. +Now, there is a lot of ways to add actions to a Updux object. It can be defined when the object is created: @@ -101,7 +101,6 @@ For TypeScript projects I recommend declaring the actions as part of the configuration passed to the constructors, as it makes them accessible to the class at compile-time, and allows Updux to auto-add them to its aggregated `actions` type. - ``` const todosUpdux = new Updux({ actions: { @@ -121,7 +120,7 @@ const myAction = ( todosUpdux.actions as any).todo_done(1); ### Accessing actions -Once an action is defined, its creator is accessible via the `actions` accessor. +Once an action is defined, its creator is accessible via the `actions` accessor. ``` console.log( todosUpdux.actions.add_todo('write tutorial') ); @@ -195,8 +194,8 @@ todos.addMutation( add_todo, description => ({next_id: id, todos}) => { }); ``` -This time around, if the project is using TypeScript then the addition of -mutations via `addMutation` is encouraged, as the method signature +This time around, if the project is using TypeScript then the addition of +mutations via `addMutation` is encouraged, as the method signature has visibility of the types of the action and state. ### Leftover mutation @@ -210,7 +209,6 @@ todosUpdux.addMutation( '*', (payload,action) => state => { }); ``` - ## Effects In addition to mutations, Updux also provides action-specific middleware, here @@ -221,13 +219,13 @@ Effects use the usual Redux middleware signature: ``` import u from 'updeep'; -// we want to decouple the increment of next_id and the creation of +// we want to decouple the increment of next_id and the creation of // a new todo. So let's use a new version of the action 'add_todo'. const add_todo_with_id = action('add_todo_with_id', payload<{description: string; id?: number}>() ); const inc_next_id = action('inc_next_id'); -const populate_next_id = ({ getState, dispatch }) => next => action => { +const populate_next_id = ({ getState, dispatch }) => next => action => { const { next_id: id } = getState(); dispatch(inc_next_id()); @@ -258,13 +256,13 @@ const todosUpdux = new Updux({ todosUpdux.addEffect( add_todo, populate_next_id ); ``` -As for the mutations, for TypeScript projects -the use of `addEffect` is prefered, as the method gives visibility to the +As for the mutations, for TypeScript projects +the use of `addEffect` is prefered, as the method gives visibility to the action and state types. ### Catch-all effect -It is possible to have an effect match all actions via the special `*` token. +It is possible to have an effect match all actions via the special `*` token. ``` todosUpdux.addEffect('*', () => next => action => { @@ -289,12 +287,12 @@ const getTodoById = ({todos}) => id => fp.find({id},todos); const todosUpdux = new Updux({ selectors: { - getTodoById + getTodoById } }) ``` -or +or ``` todosUpdux.addSelector('getTodoById', ({todos}) => id => fp.find({id},todos)); @@ -312,7 +310,7 @@ Selectors are available via the accessor `selectors`. ``` const store = todosUpdux.createStore(); -console.log( +console.log( todosUpdux.selectors.getTodoById( store.getState() )(1) ); ``` @@ -346,10 +344,10 @@ type TodoStore = { }; const add_todo = action('add_todo', payload() ); -const add_todo_with_id = action('add_todo_with_id', +const add_todo_with_id = action('add_todo_with_id', payload<{ description: string; id: number }>() ); const todo_done = action('todo_done', payload() ); -const increment_next_id = action('increment_next_id'); +const increment_next_id = action('increment_next_id'); const todosUpdux = new Updux({ initial: { @@ -367,7 +365,7 @@ const todosUpdux = new Updux({ } }); -todosUpdux.addMutation( add_todo_with_id, payload => +todosUpdux.addMutation( add_todo_with_id, payload => u.updateIn( 'todos', todos => [ ...todos, { ...payload, done: false }] ) ); @@ -375,9 +373,9 @@ todosUpdux.addMutation( increment_next_id, () => u({ next_id: i => i + 1 }) ); todosUpdux.addMutation( todo_done, id => u.updateIn( 'todos', u.map( u.if( fp.matches({id}), todo => u({done: true}, todo) ) ) -) ); +) ); -todosUpdux.addEffect( add_todo, ({ getState, dispatch }) => next => action => { +todosUpdux.addEffect( add_todo, ({ getState, dispatch }) => next => action => { const { next_id: id } = getState(); dispatch(inc_next_id()); @@ -474,14 +472,14 @@ import todo from './todo'; type TodoState = DuxState; -const add_todo_with_id = action('add_todo_with_id', - payload<{ description: string; id: number }>() +const add_todo_with_id = action('add_todo_with_id', + payload<{ description: string; id: number }>() ); const updux = new Updux({ initial: [] as Todo[], subduxes: { - '*': todo.upreducer + '*': todo.upreducer }, actions: { add_todo_with_id, @@ -491,14 +489,14 @@ const updux = new Updux({ } }); -todosUpdux.addMutation( add_todo_with_id, payload => +todosUpdux.addMutation( add_todo_with_id, payload => todos => [ ...todos, { ...payload, done: false }] ); export default updux.asDux; ``` -Note the special '*' subdux key used here. This +Note the special '\*' subdux key used here. This allows the updux to map every item present in its state to a `todo` updux. See [this recipe](/recipes?id=mapping-a-mutation-to-all-values-of-a-state) for details. We could also have written the updux as: @@ -519,11 +517,9 @@ const updux = new Updux({ ``` Note how we are using the `upreducer` accessor in the first case (which yields -a reducer for the dux using the signature `(payload,action) => state => -new_state`) and `reducer` in the second case (which yield an equivalent +a reducer for the dux using the signature `(payload,action) => state => new_state`) and `reducer` in the second case (which yield an equivalent reducer using the classic signature `(state,action) => new_state`). - ### Main store ``` @@ -546,7 +542,7 @@ const updux = new Updux({ } }); -todos.addEffect( add_todo, ({ getState, dispatch }) => next => action => { +todos.addEffect( add_todo, ({ getState, dispatch }) => next => action => { const id = updux.selectors.getNextId( getState() ); dispatch(updux.actions.inc_next_id()); @@ -560,9 +556,9 @@ export default updux.asDux; ``` -Tadah! We had to define the `add_todo` effect at the top level as it needs to +Tadah! We had to define the `add_todo` effect at the top level as it needs to access the `getNextId` selector from `next_id` and the `add_todo_with_id` -action from the `todos`. +action from the `todos`. Note that the `getNextId` selector still gets the right value; when aggregating subduxes selectors Updux auto-wraps them to @@ -573,6 +569,50 @@ at the main level is actually defined as: const getNextId = state => next_id.selectors.getNextId(state.next_id); ``` +## Subscriptions + +Subscriptions can be added by default to a updux store via the initial config +or the method `addSubscription`. The signature of a subscription is: + +``` +(store) => (state,unsubscribe) => { + ... +} +``` + +Subscriptions registered for an updux and its subduxes are automatically +subscribed to the store when calling `createStore`. + +The `state` passed to the subscriptions of the subduxes is the local state. + +Also, all subscriptions are wrapped such that they are called only if the +local `state` changed since their last invocation. + +Example: + +``` +const set_nbr_todos = action('set_nbr_todos', payload() ); + +const todos = dux({ + initial: [], + subscriptions: [ + ({dispatch}) => todos => dispatch(set_nbr_todos(todos.length)) + ], +}); + +const myDux = dux({ + initial: { + nbr_todos: 0 + }, + subduxes: { + todos, + }, + mutations: [ + [ set_nbr_todos, nbr_todos => u({nbr_todos}) ] + ] +}) +``` + ## Exporting upduxes As a general rule, don't directly export your upduxes, but rather use the accessor `asDux`. @@ -588,7 +628,7 @@ export default updux.asDux; `asDux` returns an immutable copy of the attributes of the updux. Exporting this instead of the updux itself prevents unexpected modifications done outside of the updux declaration file. More importantly, the output of -`asDux` has more precise typing, which in result results in better typing of +`asDux` has more precise typing, which in result results in better typing of parent upduxes using the dux as one of its subduxes. [immer]: https://www.npmjs.com/package/immer diff --git a/package.json b/package.json index 9576e3e..0fad8c5 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,9 @@ "scripts": { "docsify:serve": "docsify serve docs", "build": "tsc", - "test": "tap src/**test.ts" + "test": "tap src/**test.ts", + "lint": "prettier -c --", + "lint:fix": "prettier --write --" }, "version": "2.0.0", "repository": { diff --git a/src/subscriptions.test.ts b/src/subscriptions.test.ts new file mode 100644 index 0000000..56da93b --- /dev/null +++ b/src/subscriptions.test.ts @@ -0,0 +1,107 @@ +import tap from 'tap'; +import Updux from '.'; +import { action, payload } from 'ts-action'; +import u from 'updeep'; + +const inc = action('inc'); +const set_double = action('set_double', payload()); + +const dux = new Updux({ + initial: { + x: 0, + double: 0, + }, + actions: { + inc, + }, + mutations: [ + [inc, payload => u({ x: x => x + 1 })], + [set_double, double => u({ double })], + ], +}); + +dux.addSubscription(store => (state, unsubscribe) => { + if (state.x > 2) return unsubscribe(); + + store.dispatch(set_double(state.x * 2)); +}); + +const store = dux.createStore(); + +store.dispatch(inc()); + +tap.same(store.getState(), { x: 1, double: 2 }); + +store.dispatch(inc()); +store.dispatch(inc()); + +tap.same(store.getState(), { x: 3, double: 4 }, 'we unsubscribed'); + +tap.test('subduxes subscriptions', async t => { + const inc_top = action('inc_top'); + const inc_bar = action('inc_bar'); + const transform_bar = action('transform_bar', payload()); + + const bar = new Updux({ + initial: 'a', + mutations: [ + [inc_bar, () => state => state + 'a'], + [transform_bar, outcome => () => outcome], + ], + subscriptions: [ + store => (state, unsubscribe) => { + console.log({ state }); + + if (state.length <= 2) return; + unsubscribe(); + store.dispatch(transform_bar('look at ' + state)); + }, + ], + }); + + const dux = new Updux({ + initial: { + count: 0, + }, + subduxes: { + bar: bar.asDux, + }, + mutations: [[inc_top, () => u({ count: count => count + 1 })]], + effects: [ + [ + '*', + () => next => action => { + console.log('before ', action.type); + next(action); + console.log({ action }); + }, + ], + ], + subscriptions: [ + store => { + let previous: any; + return ({ count }) => { + if (count !== previous) { + previous = count; + store.dispatch(inc_bar()); + } + }; + }, + ], + }); + + const store = dux.createStore(); + + store.dispatch(inc_top()); + store.dispatch(inc_top()); + + t.same(store.getState(), { + count: 2, + bar: 'look at look at aaa', + }); + store.dispatch(inc_top()); + t.same(store.getState(), { + count: 3, + bar: 'look at look at aaaa', + }); +}); diff --git a/src/types.ts b/src/types.ts index 086e927..b522cf0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,324 +37,339 @@ export type UnionToIntersection = (U extends any export type StateOf = D extends { initial: infer I } ? I : unknown; -export type DuxStateCoduxes = C extends Array ? UnionToIntersection>: unknown -export type DuxStateSubduxes = - C extends { '*': infer I } ? { - [ key: string ]: StateOf, - [ index: number ]: StateOf, -} : - C extends object ? { [ K in keyof C]: StateOf}: unknown; +export type DuxStateCoduxes = C extends Array + ? UnionToIntersection> + : unknown; +export type DuxStateSubduxes = C extends { '*': infer I } + ? { + [key: string]: StateOf; + [index: number]: StateOf; + } + : C extends object + ? { [K in keyof C]: StateOf } + : unknown; type DuxStateGlobSub = S extends { '*': infer I } ? StateOf : unknown; type LocalDuxState = S extends never[] ? unknown[] : S; /** @ignore */ -type AggDuxState2 = ( - L extends never[] ? Array> : L & DuxStateSubduxes ) & DuxStateCoduxes; +type AggDuxState2 = (L extends never[] + ? Array> + : L & DuxStateSubduxes) & + DuxStateCoduxes; /** @ignore */ -export type AggDuxState = unknown extends O ? - AggDuxState2 : O - +export type AggDuxState = unknown extends O + ? AggDuxState2 + : O; type SelectorsOf = C extends { selectors: infer S } ? S : unknown; /** @ignore */ -export type DuxSelectorsSubduxes = C extends object ? UnionToIntersection> : unknown; +export type DuxSelectorsSubduxes = C extends object + ? UnionToIntersection> + : unknown; /** @ignore */ -export type DuxSelectorsCoduxes = C extends Array ? UnionToIntersection> : unknown; +export type DuxSelectorsCoduxes = C extends Array + ? UnionToIntersection> + : unknown; -type MaybeReturnType = X extends (...args: any) => any ? ReturnType : unknown; +type MaybeReturnType = X extends (...args: any) => any + ? ReturnType + : unknown; -type RebaseSelector = { - [ K in keyof X]: (state: S) => MaybeReturnType< X[K] > -} +type RebaseSelector = { + [K in keyof X]: (state: S) => MaybeReturnType; +}; type ActionsOf = C extends { actions: infer A } ? A : {}; type DuxActionsSubduxes = C extends object ? ActionsOf : unknown; -export type DuxActionsCoduxes = C extends Array ? UnionToIntersection> : {}; +export type DuxActionsCoduxes = C extends Array + ? UnionToIntersection> + : {}; -type ItemsOf = C extends object? C[keyof C] : unknown +type ItemsOf = C extends object ? C[keyof C] : unknown; -export type DuxActions = A extends object ? A: ( - UnionToIntersection|ItemsOf>> - ); +export type DuxActions = A extends object + ? A + : UnionToIntersection< + ActionsOf | ItemsOf> + >; -export type DuxSelectors = unknown extends X ? ( - RebaseSelector & - DuxSelectorsSubduxes > -): X +export type DuxSelectors = unknown extends X + ? RebaseSelector< + S, + C['selectors'] & + DuxSelectorsCoduxes & + DuxSelectorsSubduxes + > + : X; -export type Dux< - S = unknown, - A = unknown, - X = unknown, - C = unknown, -> = { - subduxes: Dictionary, - coduxes: Dux[], - initial: AggDuxState, - actions: A, -} +export type Dux = { + subduxes: Dictionary; + coduxes: Dux[]; + initial: AggDuxState; + actions: A; + subscriptions: Function[]; +}; /** -* Configuration object given to Updux's constructor. -* -* #### arguments -* -* ##### initial -* -* Default initial state of the reducer. If applicable, is merged with -* the subduxes initial states, with the parent having precedence. -* -* If not provided, defaults to an empty object. -* -* ##### actions -* -* [Actions](/concepts/Actions) used by the updux. -* -* ```js -* import { dux } from 'updux'; -* import { action, payload } from 'ts-action'; -* -* const bar = action('BAR', payload()); -* const foo = action('FOO'); -* -* const myDux = dux({ -* actions: { -* bar -* }, -* mutations: [ -* [ foo, () => state => state ] -* ] -* }); -* -* myDux.actions.foo({ x: 1, y: 2 }); // => { type: foo, x:1, y:2 } -* myDux.actions.bar(2); // => { type: bar, payload: 2 } -* ``` -* -* New actions used directly in mutations and effects will be added to the -* dux actions -- that is, they will be accessible via `dux.actions` -- but will -* not appear as part of its Typescript type. -* -* ##### selectors -* -* Dictionary of selectors for the current updux. The updux also -* inherit its subduxes' selectors. -* -* The selectors are available via the class' getter. -* -* ##### mutations -* -* mutations: [ -* [ action, mutation, isSink ], -* ... -* ] -* -* or -* -* mutations: { -* action: mutation, -* ... -* } -* -* List of mutations for assign to the dux. If you want Typescript goodness, you -* probably want to use `addMutation()` instead. -* -* In its generic array-of-array form, -* each mutation tuple contains: the action, the mutation, -* and boolean indicating if this is a sink mutation. -* -* The action can be an action creator function or a string. If it's a string, it's considered to be the -* action type and a generic `action( actionName, payload() )` creator will be -* generated for it. If an action is not already defined in the `actions` -* parameter, it'll be automatically added. -* -* The pseudo-action type `*` can be used to match any action not explicitly matched by other mutations. -* -* ```js -* const todosUpdux = updux({ -* mutations: { -* add: todo => state => [ ...state, todo ], -* done: done_id => u.map( u.if( ({id} => id === done_id), {done: true} ) ), -* '*' (payload,action) => state => { -* console.warn( "unexpected action ", action.type ); -* return state; -* }, -* } -* }); -* ``` -* -* The signature of the mutations is `(payload,action) => state => newState`. -* It is designed to play well with `Updeep` (and [Immer](https://immerjs.github.io/immer/docs/introduction)). This way, instead of doing -* -* ```js -* mutation: { -* renameTodo: newName => state => { ...state, name: newName } -* } -* ``` -* -* we can do -* -* ```js -* mutation: { -* renameTodo: newName => u({ name: newName }) -* } -* ``` -* -* The final argument is the optional boolean `isSink`. If it is true, it'll -* prevent subduxes' mutations on the same action. It defaults to `false`. -* -* The object version of the argument can be used as a shortcut when all actions -* are strings. In that case, `isSink` is `false` for all mutations. -* -* ##### groomMutations -* -* Function that can be provided to alter all local mutations of the updux -* (the mutations of subduxes are left untouched). -* -* Can be used, for example, for Immer integration: -* -* ```js -* import Updux from 'updux'; -* import { produce } from 'Immer'; -* -* const updux = new Updux({ -* initial: { counter: 0 }, -* groomMutations: mutation => (...args) => produce( mutation(...args) ), -* mutations: { -* add: (inc=1) => draft => draft.counter += inc -* } -* }); -* ``` -* -* Or perhaps for debugging: -* -* ```js -* import Updux from 'updux'; -* -* const updux = new Updux({ -* initial: { counter: 0 }, -* groomMutations: mutation => (...args) => state => { -* console.log( "got action ", args[1] ); -* return mutation(...args)(state); -* } -* }); -* ``` -* ##### subduxes -* -* Object mapping slices of the state to sub-upduxes. In addition to creating -* sub-reducers for those slices, it'll make the parend updux inherit all the -* actions and middleware from its subduxes. -* -* For example, if in plain Redux you would do -* -* ```js -* import { combineReducers } from 'redux'; -* import todosReducer from './todos'; -* import statisticsReducer from './statistics'; -* -* const rootReducer = combineReducers({ -* todos: todosReducer, -* stats: statisticsReducer, -* }); -* ``` -* -* then with Updux you'd do -* -* ```js -* import { updux } from 'updux'; -* import todos from './todos'; -* import statistics from './statistics'; -* -* const rootUpdux = updux({ -* subduxes: { -* todos, -* statistics -* } -* }); -* ``` -* -* ##### effects -* -* Array of arrays or plain object defining asynchronous actions and side-effects triggered by actions. -* The effects themselves are Redux middleware, with the `dispatch` -* property of the first argument augmented with all the available actions. -* -* ``` -* updux({ -* effects: { -* fetch: ({dispatch}) => next => async (action) => { -* next(action); -* -* let result = await fetch(action.payload.url).then( result => result.json() ); -* dispatch.fetchSuccess(result); -* } -* } -* }); -* ``` -* -* @example -* -* ``` -* import Updux from 'updux'; -* import { actions, payload } from 'ts-action'; -* import u from 'updeep'; -* -* const todoUpdux = new Updux({ -* initial: { -* done: false, -* note: "", -* }, -* actions: { -* finish: action('FINISH', payload()), -* edit: action('EDIT', payload()), -* }, -* mutations: [ -* [ edit, note => u({note}) ] -* ], -* selectors: { -* getNote: state => state.note -* }, -* groomMutations: mutation => transform(mutation), -* subduxes: { -* foo -* }, -* effects: { -* finish: () => next => action => { -* console.log( "Woo! one more bites the dust" ); -* } -* } -* }) -* ``` -*/ + * Configuration object given to Updux's constructor. + * + * #### arguments + * + * ##### initial + * + * Default initial state of the reducer. If applicable, is merged with + * the subduxes initial states, with the parent having precedence. + * + * If not provided, defaults to an empty object. + * + * ##### actions + * + * [Actions](/concepts/Actions) used by the updux. + * + * ```js + * import { dux } from 'updux'; + * import { action, payload } from 'ts-action'; + * + * const bar = action('BAR', payload()); + * const foo = action('FOO'); + * + * const myDux = dux({ + * actions: { + * bar + * }, + * mutations: [ + * [ foo, () => state => state ] + * ] + * }); + * + * myDux.actions.foo({ x: 1, y: 2 }); // => { type: foo, x:1, y:2 } + * myDux.actions.bar(2); // => { type: bar, payload: 2 } + * ``` + * + * New actions used directly in mutations and effects will be added to the + * dux actions -- that is, they will be accessible via `dux.actions` -- but will + * not appear as part of its Typescript type. + * + * ##### selectors + * + * Dictionary of selectors for the current updux. The updux also + * inherit its subduxes' selectors. + * + * The selectors are available via the class' getter. + * + * ##### mutations + * + * mutations: [ + * [ action, mutation, isSink ], + * ... + * ] + * + * or + * + * mutations: { + * action: mutation, + * ... + * } + * + * List of mutations for assign to the dux. If you want Typescript goodness, you + * probably want to use `addMutation()` instead. + * + * In its generic array-of-array form, + * each mutation tuple contains: the action, the mutation, + * and boolean indicating if this is a sink mutation. + * + * The action can be an action creator function or a string. If it's a string, it's considered to be the + * action type and a generic `action( actionName, payload() )` creator will be + * generated for it. If an action is not already defined in the `actions` + * parameter, it'll be automatically added. + * + * The pseudo-action type `*` can be used to match any action not explicitly matched by other mutations. + * + * ```js + * const todosUpdux = updux({ + * mutations: { + * add: todo => state => [ ...state, todo ], + * done: done_id => u.map( u.if( ({id} => id === done_id), {done: true} ) ), + * '*' (payload,action) => state => { + * console.warn( "unexpected action ", action.type ); + * return state; + * }, + * } + * }); + * ``` + * + * The signature of the mutations is `(payload,action) => state => newState`. + * It is designed to play well with `Updeep` (and [Immer](https://immerjs.github.io/immer/docs/introduction)). This way, instead of doing + * + * ```js + * mutation: { + * renameTodo: newName => state => { ...state, name: newName } + * } + * ``` + * + * we can do + * + * ```js + * mutation: { + * renameTodo: newName => u({ name: newName }) + * } + * ``` + * + * The final argument is the optional boolean `isSink`. If it is true, it'll + * prevent subduxes' mutations on the same action. It defaults to `false`. + * + * The object version of the argument can be used as a shortcut when all actions + * are strings. In that case, `isSink` is `false` for all mutations. + * + * ##### groomMutations + * + * Function that can be provided to alter all local mutations of the updux + * (the mutations of subduxes are left untouched). + * + * Can be used, for example, for Immer integration: + * + * ```js + * import Updux from 'updux'; + * import { produce } from 'Immer'; + * + * const updux = new Updux({ + * initial: { counter: 0 }, + * groomMutations: mutation => (...args) => produce( mutation(...args) ), + * mutations: { + * add: (inc=1) => draft => draft.counter += inc + * } + * }); + * ``` + * + * Or perhaps for debugging: + * + * ```js + * import Updux from 'updux'; + * + * const updux = new Updux({ + * initial: { counter: 0 }, + * groomMutations: mutation => (...args) => state => { + * console.log( "got action ", args[1] ); + * return mutation(...args)(state); + * } + * }); + * ``` + * ##### subduxes + * + * Object mapping slices of the state to sub-upduxes. In addition to creating + * sub-reducers for those slices, it'll make the parend updux inherit all the + * actions and middleware from its subduxes. + * + * For example, if in plain Redux you would do + * + * ```js + * import { combineReducers } from 'redux'; + * import todosReducer from './todos'; + * import statisticsReducer from './statistics'; + * + * const rootReducer = combineReducers({ + * todos: todosReducer, + * stats: statisticsReducer, + * }); + * ``` + * + * then with Updux you'd do + * + * ```js + * import { updux } from 'updux'; + * import todos from './todos'; + * import statistics from './statistics'; + * + * const rootUpdux = updux({ + * subduxes: { + * todos, + * statistics + * } + * }); + * ``` + * + * ##### effects + * + * Array of arrays or plain object defining asynchronous actions and side-effects triggered by actions. + * The effects themselves are Redux middleware, with the `dispatch` + * property of the first argument augmented with all the available actions. + * + * ``` + * updux({ + * effects: { + * fetch: ({dispatch}) => next => async (action) => { + * next(action); + * + * let result = await fetch(action.payload.url).then( result => result.json() ); + * dispatch.fetchSuccess(result); + * } + * } + * }); + * ``` + * + * @example + * + * ``` + * import Updux from 'updux'; + * import { actions, payload } from 'ts-action'; + * import u from 'updeep'; + * + * const todoUpdux = new Updux({ + * initial: { + * done: false, + * note: "", + * }, + * actions: { + * finish: action('FINISH', payload()), + * edit: action('EDIT', payload()), + * }, + * mutations: [ + * [ edit, note => u({note}) ] + * ], + * selectors: { + * getNote: state => state.note + * }, + * groomMutations: mutation => transform(mutation), + * subduxes: { + * foo + * }, + * effects: { + * finish: () => next => action => { + * console.log( "Woo! one more bites the dust" ); + * } + * } + * }) + * ``` + */ export type UpduxConfig = Partial<{ - initial: unknown, /** foo */ - subduxes: Dictionary, - coduxes: Dux[], - actions: Dictionary, - selectors: Dictionary, - mutations: any, - groomMutations: (m: Mutation) => Mutation, - effects: any, + initial: unknown /** foo */; + subduxes: Dictionary; + coduxes: Dux[]; + actions: Dictionary; + selectors: Dictionary; + mutations: any; + groomMutations: (m: Mutation) => Mutation; + effects: any; + subscriptions: Function[]; }>; - export type Upreducer = (action: Action) => (state: S) => S; /** @ignore */ -export interface UpduxMiddlewareAPI> { +export interface UpduxMiddlewareAPI> { dispatch: Function; getState(): S; selectors: X; actions: Dictionary; } -export type UpduxMiddleware,A = Action> = ( - api: UpduxMiddlewareAPI +export type UpduxMiddleware, A = Action> = ( + api: UpduxMiddlewareAPI ) => (next: Function) => (action: A) => any; export type Selector = (state: S) => unknown; diff --git a/src/updux.ts b/src/updux.ts index 383ad6e..2cec98b 100644 --- a/src/updux.ts +++ b/src/updux.ts @@ -1,6 +1,6 @@ import fp from 'lodash/fp'; import { action, payload, ActionCreator, ActionType } from 'ts-action'; -import {AnyAction} from 'redux'; +import { AnyAction } from 'redux'; import buildInitial from './buildInitial'; import buildMutations from './buildMutations'; @@ -51,6 +51,39 @@ type StoreWithDispatchActions< dispatch: { [type in keyof Actions]: (...args: any) => void }; }; +function wrap_subscription(sub) { + return store => { + const sub_curried = sub(store); + let previous: unknown; + + return (state, unsubscribe) => { + if (state === previous) return; + previous = state; + + return sub_curried(state, unsubscribe); + }; + }; +} + +function _subscribeToStore(store: any, subscriptions: Function[] = []) { + subscriptions.forEach(sub => { + const subscriber = sub(store); + let unsub = store.subscribe(() => { + const state = store.getState(); + return subscriber(state, unsub); + }); + }); +} + +function sliced_subscription(slice, sub) { + return store => { + const sub_curried = sub(store); + + return (state, unsubscribe) => + sub_curried(fp.get(slice, state), unsubscribe); + }; +} + /** * @public * `Updux` is a way to minimize and simplify the boilerplate associated with the @@ -59,80 +92,85 @@ type StoreWithDispatchActions< * In true `Redux`-like fashion, upduxes can be made of sub-upduxes (`subduxes` for short) for different slices of the root state. */ export class Updux< - S = unknown, - A = null, - X = unknown, - C extends UpduxConfig = {} - > { - subduxes: Dictionary; - coduxes: Dux[]; + S = unknown, + A = null, + X = unknown, + C extends UpduxConfig = {} +> { + subduxes: Dictionary; + coduxes: Dux[]; - private localSelectors: Dictionary = {}; + private localSelectors: Dictionary = {}; - private localInitial: unknown; + private localInitial: unknown; - groomMutations: (mutation: Mutation) => Mutation; + groomMutations: (mutation: Mutation) => Mutation; - private localEffects: Effect[] = []; + private localEffects: Effect[] = []; - private localActions: Dictionary = {}; + private localActions: Dictionary = {}; - private localMutations: Dictionary< - Mutation | [Mutation, boolean | undefined] - > = {}; + private localMutations: Dictionary< + Mutation | [Mutation, boolean | undefined] + > = {}; - get initial(): AggDuxState { - return buildInitial( - this.localInitial, - this.coduxes.map(({ initial }) => initial), - fp.mapValues('initial', this.subduxes) - ) as any; - } + private localSubscriptions: Function[] = []; - /** - * @param config an [[UpduxConfig]] plain object - * - */ - constructor(config: C = {} as C) { - this.localInitial = config.initial ?? {}; - this.localSelectors = config.selectors ?? {}; - this.coduxes = config.coduxes ?? []; - this.subduxes = config.subduxes ?? {}; + get initial(): AggDuxState { + return buildInitial( + this.localInitial, + this.coduxes.map(({ initial }) => initial), + fp.mapValues('initial', this.subduxes) + ) as any; + } - Object.entries(config.actions ?? {}).forEach((args) => - (this.addAction as any)(...args) - ); + /** + * @param config an [[UpduxConfig]] plain object + * + */ + constructor(config: C = {} as C) { + this.localInitial = config.initial ?? {}; + this.localSelectors = config.selectors ?? {}; + this.coduxes = config.coduxes ?? []; + this.subduxes = config.subduxes ?? {}; - this.coduxes.forEach((c: any) => - Object.entries(c.actions).forEach((args) => - (this.addAction as any)(...args) - ) - ); - Object.values(this.subduxes).forEach((c: any) => { - Object.entries(c.actions).forEach((args) => { - (this.addAction as any)(...args); - }); - }); + Object.entries(config.actions ?? {}).forEach(args => + (this.addAction as any)(...args) + ); - this.groomMutations = - config.groomMutations ?? ((x: Mutation) => x); + this.coduxes.forEach((c: any) => + Object.entries(c.actions).forEach(args => + (this.addAction as any)(...args) + ) + ); + Object.values(this.subduxes).forEach((c: any) => { + Object.entries(c.actions).forEach(args => { + (this.addAction as any)(...args); + }); + }); - let effects = config.effects ?? []; + if (config.subscriptions) { + config.subscriptions.forEach(sub => this.addSubscription(sub)); + } - if (!Array.isArray(effects)) { - effects = (Object.entries(effects) as unknown) as Effect[]; - } - effects.forEach((effect) => (this.addEffect as any)(...effect)); + this.groomMutations = config.groomMutations ?? ((x: Mutation) => x); - let mutations = config.mutations ?? []; + let effects = config.effects ?? []; - if (!Array.isArray(mutations)) { - mutations = fp.toPairs(mutations); - } + if (!Array.isArray(effects)) { + effects = (Object.entries(effects) as unknown) as Effect[]; + } + effects.forEach(effect => (this.addEffect as any)(...effect)); - mutations.forEach((args) => (this.addMutation as any)(...args)); + let mutations = config.mutations ?? []; - /* + if (!Array.isArray(mutations)) { + mutations = fp.toPairs(mutations); + } + + mutations.forEach(args => (this.addMutation as any)(...args)); + + /* Object.entries(selectors).forEach(([name, sel]: [string, Function]) => this.addSelector(name, sel as Selector) @@ -150,373 +188,389 @@ export class Updux< ); */ - } + } - /** - * Array of middlewares aggregating all the effects defined in the - * updux and its subduxes. Effects of the updux itself are - * done before the subduxes effects. - * Note that `getState` will always return the state of the - * local updux. - * - * @example - * - * ``` - * const middleware = updux.middleware; - * ``` - */ - get middleware(): UpduxMiddleware< - AggDuxState, - DuxSelectors, X, C> - > { - const selectors = this.selectors; - const actions = this.actions; - return buildMiddleware( - this.localEffects.map((effect) => - effectToMw(effect, actions as any, selectors as any) - ), - (this.coduxes as any).map(fp.get('middleware')) as any, - fp.mapValues('middleware', this.subduxes) - ) as any; - } + /** + * Array of middlewares aggregating all the effects defined in the + * updux and its subduxes. Effects of the updux itself are + * done before the subduxes effects. + * Note that `getState` will always return the state of the + * local updux. + * + * @example + * + * ``` + * const middleware = updux.middleware; + * ``` + */ + get middleware(): UpduxMiddleware< + AggDuxState, + DuxSelectors, X, C> + > { + const selectors = this.selectors; + const actions = this.actions; + return buildMiddleware( + this.localEffects.map(effect => + effectToMw(effect, actions as any, selectors as any) + ), + (this.coduxes as any).map(fp.get('middleware')) as any, + fp.mapValues('middleware', this.subduxes) + ) as any; + } - /** - * Action creators for all actions defined or used in the actions, mutations, effects and subduxes - * of the updux config. - * - * Non-custom action creators defined in `actions` have the signature `(payload={},meta={}) => ({type, - * payload,meta})` (with the extra sugar that if `meta` or `payload` are not - * specified, that key won't be present in the produced action). - * - * The same action creator can be included - * in multiple subduxes. However, if two different creators - * are included for the same action, an error will be thrown. - * - * @example - * - * ``` - * const actions = updux.actions; - * ``` - */ - get actions(): DuxActions { - // UpduxActions> { - return this.localActions as any; - } + /** + * Action creators for all actions defined or used in the actions, mutations, effects and subduxes + * of the updux config. + * + * Non-custom action creators defined in `actions` have the signature `(payload={},meta={}) => ({type, + * payload,meta})` (with the extra sugar that if `meta` or `payload` are not + * specified, that key won't be present in the produced action). + * + * The same action creator can be included + * in multiple subduxes. However, if two different creators + * are included for the same action, an error will be thrown. + * + * @example + * + * ``` + * const actions = updux.actions; + * ``` + */ + get actions(): DuxActions { + // UpduxActions> { + return this.localActions as any; + } - get upreducer(): Upreducer { - return buildUpreducer( - this.initial, - this.mutations as any - ) as any; - } + get upreducer(): Upreducer { + return buildUpreducer(this.initial, this.mutations as any) as any; + } - /** - * A Redux reducer generated using the computed initial state and - * mutations. - */ - get reducer(): (state: S | undefined, action: Action) => S { - return (state, action) => this.upreducer(action)(state as S); - } + /** + * A Redux reducer generated using the computed initial state and + * mutations. + */ + get reducer(): (state: S | undefined, action: Action) => S { + return (state, action) => this.upreducer(action)(state as S); + } - /** - * Merge of the updux and subduxes mutations. If an action triggers - * mutations in both the main updux and its subduxes, the subduxes - * mutations will be performed first. - */ - get mutations(): Dictionary> { - return buildMutations( - this.localMutations, - fp.mapValues('upreducer', this.subduxes as any), - fp.map('upreducer', this.coduxes as any) - ); - } + /** + * Merge of the updux and subduxes mutations. If an action triggers + * mutations in both the main updux and its subduxes, the subduxes + * mutations will be performed first. + */ + get mutations(): Dictionary> { + return buildMutations( + this.localMutations, + fp.mapValues('upreducer', this.subduxes as any), + fp.map('upreducer', this.coduxes as any) + ); + } - /** - * Returns the upreducer made of the merge of all sudbuxes reducers, without - * the local mutations. Useful, for example, for sink mutations. - * - * @example - * - * ``` - * import todo from './todo'; // updux for a single todo - * import Updux from 'updux'; - * import u from 'updeep'; - * - * const todos = new Updux({ initial: [], subduxes: { '*': todo } }); - * todos.addMutation( - * todo.actions.done, - * ({todo_id},action) => u.map( u.if( u.is('id',todo_id) ), todos.subduxUpreducer(action) ) - * true - * ); - * ``` - * - * - * - */ - get subduxUpreducer() { - return buildUpreducer( - this.initial, - buildMutations({}, this.subduxes) - ); - } + /** + * Returns the upreducer made of the merge of all sudbuxes reducers, without + * the local mutations. Useful, for example, for sink mutations. + * + * @example + * + * ``` + * import todo from './todo'; // updux for a single todo + * import Updux from 'updux'; + * import u from 'updeep'; + * + * const todos = new Updux({ initial: [], subduxes: { '*': todo } }); + * todos.addMutation( + * todo.actions.done, + * ({todo_id},action) => u.map( u.if( u.is('id',todo_id) ), todos.subduxUpreducer(action) ) + * true + * ); + * ``` + * + * + * + */ + get subduxUpreducer() { + return buildUpreducer(this.initial, buildMutations({}, this.subduxes)); + } - /** - * Returns a `createStore` function that takes two argument: - * `initial` and `injectEnhancer`. `initial` is a custom - * initial state for the store, and `injectEnhancer` is a function - * taking in the middleware built by the updux object and allowing - * you to wrap it in any enhancer you want. - * - * @example - * - * ``` - * const createStore = updux.createStore; - * - * const store = createStore(initial); - * ``` - * - * - * - */ - get createStore() { - return buildCreateStore, DuxActions>( - this.reducer as any, - this.middleware as any, - this.actions - ); - } + /** + * Returns a `createStore` function that takes two argument: + * `initial` and `injectEnhancer`. `initial` is a custom + * initial state for the store, and `injectEnhancer` is a function + * taking in the middleware built by the updux object and allowing + * you to wrap it in any enhancer you want. + * + * @example + * + * ``` + * const createStore = updux.createStore; + * + * const store = createStore(initial); + * ``` + * + * + * + */ + createStore(...args: any) { + const store = buildCreateStore, DuxActions>( + this.reducer as any, + this.middleware as any, + this.actions + )(...args); - /** - * Returns a ducks-like - * plain object holding the reducer from the Updux object and all - * its trimmings. - * - * @example - * - * ``` - * const { - * createStore, - * upreducer, - * subduxes, - * coduxes, - * middleware, - * actions, - * reducer, - * mutations, - * initial, - * selectors, - * } = myUpdux.asDux; - * ``` - * - * - * - * - */ - get asDux() { - return { - createStore: this.createStore, - upreducer: this.upreducer, - subduxes: this.subduxes, - coduxes: this.coduxes, - middleware: this.middleware, - actions: this.actions, - reducer: this.reducer, - mutations: this.mutations, - initial: this.initial, - selectors: this.selectors, - }; - } + _subscribeToStore(store, this.subscriptions); - /** - * Adds a mutation and its associated action to the updux. - * - * @param isSink - If `true`, disables the subduxes mutations for this action. To - * conditionally run the subduxes mutations, check out [[subduxUpreducer]]. Defaults to `false`. - * - * @remarks - * - * If a local mutation was already associated to the action, - * it will be replaced by the new one. - * - * - * @example - * - * ```js - * updux.addMutation( - * action('ADD', payload() ), - * inc => state => state + in - * ); - * ``` - */ - addMutation( - creator: A, - mutation: Mutation>, - isSink?: boolean - ); - addMutation( - creator: string, - mutation: Mutation, - isSink?: boolean - ); - addMutation( - creator, - mutation, - isSink - ) { - const c = this.addAction(creator); + return store; + } - this.localMutations[c.type] = [ - this.groomMutations(mutation as any) as Mutation, - isSink, - ]; - } + /** + * Returns an array of all subscription functions registered for the dux. + * Subdux subscriptions are wrapped such that they are getting their + * local state. Also all subscriptions are further wrapped such that + * they are only called when the local state changed + */ + get subscriptions() { + let subscriptions = ([ + this.localSubscriptions, + Object.entries(this.subduxes).map(([slice, subdux]) => { + return subdux.subscriptions.map(sub => + sliced_subscription(slice, sub) + ); + }), + ] as any).flat(Infinity); - addEffect( - creator: AC, - middleware: UpduxMiddleware< - AggDuxState, - DuxSelectors, X, C>, - ReturnType - >, - isGenerator?: boolean - ); - addEffect( - creator: string, - middleware: UpduxMiddleware< - AggDuxState, - DuxSelectors, X, C> - >, - isGenerator?: boolean - ); - addEffect(creator, middleware, isGenerator = false) { - const c = this.addAction(creator); - this.localEffects.push([c.type, middleware, isGenerator] as any); - } + return subscriptions.map(sub => wrap_subscription(sub)); + } - // can be - //addAction( actionCreator ) - // addAction( 'foo', transform ) - /** - * Adds an action to the updux. It can take an already defined action - * creator, or any arguments that can be passed to `actionCreator`. - * @example - * ``` - * const action = updux.addAction( name, ...creatorArgs ); - * const action = updux.addAction( otherActionCreator ); - * ``` - * @example - * ``` - * import {actionCreator, Updux} from 'updux'; - * - * const updux = new Updux(); - * - * const foo = updux.addAction('foo'); - * const bar = updux.addAction( 'bar', (x) => ({stuff: x+1}) ); - * - * const baz = actionCreator( 'baz' ); - * - * foo({ a: 1}); // => { type: 'foo', payload: { a: 1 } } - * bar(2); // => { type: 'bar', payload: { stuff: 3 } } - * baz(); // => { type: 'baz', payload: undefined } - * ``` - */ - addAction( - theaction: string, - transform?: any - ): ActionCreator; - addAction( - theaction: string | ActionCreator, - transform?: never - ): ActionCreator; - addAction(actionIn: any, transform: any) { - let name: string; - let creator: ActionCreator; + /** + * Returns a ducks-like + * plain object holding the reducer from the Updux object and all + * its trimmings. + * + * @example + * + * ``` + * const { + * createStore, + * upreducer, + * subduxes, + * coduxes, + * middleware, + * actions, + * reducer, + * mutations, + * initial, + * selectors, + * subscriptions, + * } = myUpdux.asDux; + * ``` + * + * + * + * + */ + get asDux() { + return { + createStore: this.createStore, + upreducer: this.upreducer, + subduxes: this.subduxes, + coduxes: this.coduxes, + middleware: this.middleware, + actions: this.actions, + reducer: this.reducer, + mutations: this.mutations, + initial: this.initial, + selectors: this.selectors, + subscriptions: this.subscriptions, + }; + } - if (typeof actionIn === 'string') { - name = actionIn; + /** + * Adds a mutation and its associated action to the updux. + * + * @param isSink - If `true`, disables the subduxes mutations for this action. To + * conditionally run the subduxes mutations, check out [[subduxUpreducer]]. Defaults to `false`. + * + * @remarks + * + * If a local mutation was already associated to the action, + * it will be replaced by the new one. + * + * + * @example + * + * ```js + * updux.addMutation( + * action('ADD', payload() ), + * inc => state => state + in + * ); + * ``` + */ + addMutation( + creator: A, + mutation: Mutation>, + isSink?: boolean + ); + addMutation( + creator: string, + mutation: Mutation, + isSink?: boolean + ); + addMutation(creator, mutation, isSink) { + const c = this.addAction(creator); - if (transform) { - creator = transform.type - ? transform - : action(name, (...args: any) => ({ - payload: transform(...args), - })); - } else { - creator = - this.localActions[name] ?? action(name, payload()); - } - } else { - name = actionIn.type; - creator = actionIn; - } + this.localMutations[c.type] = [ + this.groomMutations(mutation as any) as Mutation, + isSink, + ]; + } - const already = this.localActions[name]; + addEffect( + creator: AC, + middleware: UpduxMiddleware< + AggDuxState, + DuxSelectors, X, C>, + ReturnType + >, + isGenerator?: boolean + ); + addEffect( + creator: string, + middleware: UpduxMiddleware< + AggDuxState, + DuxSelectors, X, C> + >, + isGenerator?: boolean + ); + addEffect(creator, middleware, isGenerator = false) { + const c = this.addAction(creator); + this.localEffects.push([c.type, middleware, isGenerator] as any); + } - if (!already) - return ((this.localActions as any)[name] = creator) as any; + // can be + //addAction( actionCreator ) + // addAction( 'foo', transform ) + /** + * Adds an action to the updux. It can take an already defined action + * creator, or any arguments that can be passed to `actionCreator`. + * @example + * ``` + * const action = updux.addAction( name, ...creatorArgs ); + * const action = updux.addAction( otherActionCreator ); + * ``` + * @example + * ``` + * import {actionCreator, Updux} from 'updux'; + * + * const updux = new Updux(); + * + * const foo = updux.addAction('foo'); + * const bar = updux.addAction( 'bar', (x) => ({stuff: x+1}) ); + * + * const baz = actionCreator( 'baz' ); + * + * foo({ a: 1}); // => { type: 'foo', payload: { a: 1 } } + * bar(2); // => { type: 'bar', payload: { stuff: 3 } } + * baz(); // => { type: 'baz', payload: undefined } + * ``` + */ + addAction(theaction: string, transform?: any): ActionCreator; + addAction( + theaction: string | ActionCreator, + transform?: never + ): ActionCreator; + addAction(actionIn: any, transform: any) { + let name: string; + let creator: ActionCreator; - if (already !== creator && already.type !== '*') { - throw new Error(`action ${name} already exists`); - } + if (typeof actionIn === 'string') { + name = actionIn; - return already; - } + if (transform) { + creator = transform.type + ? transform + : action(name, (...args: any) => ({ + payload: transform(...args), + })); + } else { + creator = this.localActions[name] ?? action(name, payload()); + } + } else { + name = actionIn.type; + creator = actionIn; + } - get _middlewareEntries() { - const groupByOrder = (mws: any) => - fp.groupBy( - ([, , actionType]: any) => - ['^', '$'].includes(actionType) - ? actionType - : 'middle', - mws - ); + const already = this.localActions[name]; - const subs = fp.flow([ - fp.toPairs, - fp.map(([slice, updux]) => - updux._middlewareEntries.map(([u, ps, ...args]: any) => [ - u, - [slice, ...ps], - ...args, - ]) - ), - fp.flatten, - groupByOrder, - ])(this.subduxes); + if (!already) + return ((this.localActions as any)[name] = creator) as any; - const local = groupByOrder( - this.localEffects.map((x) => [this, [], ...x]) - ); + if (already !== creator && already.type !== '*') { + throw new Error(`action ${name} already exists`); + } - return fp.flatten( - [ - local['^'], - subs['^'], - local.middle, - subs.middle, - subs['$'], - local['$'], - ].filter((x) => x) - ); - } + return already; + } - addSelector(name: string, selector: Selector) { - this.localSelectors[name] = selector; - } + get _middlewareEntries() { + const groupByOrder = (mws: any) => + fp.groupBy( + ([, , actionType]: any) => + ['^', '$'].includes(actionType) ? actionType : 'middle', + mws + ); - /** + const subs = fp.flow([ + fp.toPairs, + fp.map(([slice, updux]) => + updux._middlewareEntries.map(([u, ps, ...args]: any) => [ + u, + [slice, ...ps], + ...args, + ]) + ), + fp.flatten, + groupByOrder, + ])(this.subduxes); + + const local = groupByOrder( + this.localEffects.map(x => [this, [], ...x]) + ); + + return fp.flatten( + [ + local['^'], + subs['^'], + local.middle, + subs.middle, + subs['$'], + local['$'], + ].filter(x => x) + ); + } + + addSelector(name: string, selector: Selector) { + this.localSelectors[name] = selector; + } + + /** A dictionary of the updux's selectors. Subduxes' selectors are included as well (with the mapping to the sub-state already taken care of you). */ - get selectors(): DuxSelectors, X, C> { - return buildSelectors( - this.localSelectors, - fp.map('selectors', this.coduxes), - fp.mapValues('selectors', this.subduxes) - ) as any; - } - } + get selectors(): DuxSelectors, X, C> { + return buildSelectors( + this.localSelectors, + fp.map('selectors', this.coduxes), + fp.mapValues('selectors', this.subduxes) + ) as any; + } + + /** + * Add a subscription to the dux. + */ + addSubscription(subscription: Function) { + this.localSubscriptions = [...this.localSubscriptions, subscription]; + } +} export default Updux;