diff --git a/Taskfile.yaml b/Taskfile.yaml index b7531b9..c0dbd7b 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -22,10 +22,22 @@ tasks: - npx prettier --write {{.CLI_ARGS | default "." }} - npx eslint --fix --quiet {{.CLI_ARGS | default "." }} + lint:delta: + cmds: + - task: 'lint:prettier:delta' + - task: 'lint:eslint:delta' + + lint:prettier:delta: + vars: + FILES: + sh: git diff-ls --diff-filter=d {{.ROOT_BRANCH | default "main"}} | grep -v .vitebook + cmds: + - npx prettier --check {{.FILES | catLines }} + lint:eslint:delta: vars: FILES: - sh: git diff-ls --diff-filter=d main | grep -v .vitebook + sh: git diff-ls --diff-filter=d {{.ROOT_BRANCH | default "main"}} | grep -v .vitebook cmds: - npx eslint --format=unix {{.FILES | catLines }} diff --git a/docs/tutorial-subduxes.test.js b/docs/tutorial-subduxes.test.js new file mode 100644 index 0000000..fcb5301 --- /dev/null +++ b/docs/tutorial-subduxes.test.js @@ -0,0 +1,91 @@ +import { test, expect } from 'vitest'; + +import u from 'updeep'; +import R from 'remeda'; + +import { Updux } from '../src/index.js'; + +const nextIdDux = new Updux({ + initial: 1, + actions: { + incrementNextId: null, + }, + selectors: { + getNextId: state => state + }, + mutations: { + incrementNextId: () => state => state + 1, + } +}); + +const matches = conditions => target => Object.entries(conditions).every( + ([key,value]) => typeof value === 'function' ? value(target[key]) : target[key] === value +); + +const todoDux = new Updux({ + initial: { + id: 0, + description: "", + done: false, + }, + actions: { + todoDone: null, + }, + mutations: { + todoDone: id => u.if( matches({id}), { done: true }) + }, + selectors: { + desc: R.prop('description'), + } +}); + +const todosDux = new Updux({ + initial: [], + subduxes: { + '*': todoDux + }, + actions: { + addTodoWithId: (description, id) => ({description, id} ) + }, + findSelectors: { + getTodoById: state => id => state.find(matches({id})) + }, + mutations: { + addTodoWithId: todo => todos => [...todos, todo] + } +}); + +const mainDux = new Updux({ + subduxes: { + nextId: nextIdDux, + todos: todosDux, + }, + actions: { + addTodo: null + }, + effects: { + addTodo: ({ getState, dispatch }) => next => action => { + const id = getState.getNextId(); + + dispatch.incrementNextId() + + next(action); + + dispatch.addTodoWithId( action.payload, id ); + } + } +}); + +const store = mainDux.createStore(); + + +test( "basic tests", () => { + + const myDesc = 'do the thing'; + store.dispatch.addTodo(myDesc); + + expect( store.getState.getTodoById(1).desc() ).toEqual(myDesc); + + + +}); diff --git a/docs/tutorial.md b/docs/tutorial.md index 22184c0..6626941 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -170,5 +170,187 @@ console.log( ); ``` +## Subduxes + +Now that we have all the building blocks, we can embark on the last and +funkiest part of Updux: its recursive nature. + +### Recap: the Todos dux, undivided + +Upduxes can be divided into sub-upduxes that deal with the various parts of +the global state. This is better understood by working out an example, so +let's recap on the Todos dux we have so far: + +```js +import Updux from 'updux'; +import u from 'updeep'; +import fp from 'lodash/fp'; + +const todosDux = new Updux({ + initial: { + nextId: 1, + todos: [], + }, + actions: { + addTodo: null, + addTodoWithId: (description, id) => ({description, id, done: false}), + todoDone: null, + incNextId: null, + }, + selectors: { + getTodoById: ({todos}) => id => fp.find({id},todos) + }, + mutations: { + addTodoWithId: todo => + u.updateIn( 'todos', todos => [ ...todos, todo] ), + incrementNextId: () => u({ nextId: fp.add(1) }), + todoDone: (id) => u.updateIn('todos', + u.map( u.if( fp.matches({id}), todo => u({done: true}, todo) ) ) + ), + }, + effects: { + addTodo: ({ getState, dispatch }) => next => action => { + const { nextId: id } = getState(); + + dispatch.incNextId(); + + next(action); + + dispatch.addTodoWithId(action.payload, id); + } + } +}); + +``` + +This store has two main components: the `nextId`, and the `todos` collection. +The `todos` collection is itself composed of the individual `todo`s. Let's +create upduxes for each of those. + +### NextId dux + +``` +// dux/nextId.js + +import { Updux } from 'updux'; +import u from 'updeep'; + +export default new Updux({ + initial: 1, + actions: { + incrementNextId: null, + }, + selectors: { + getNextId: state => state + }, + mutations: { + incrementNextId: () => state => state + 1, + } +}); + +``` + +### Todo updux + +``` +// dux/todos/todo/index.ts + +import { Updux } from 'updux'; +import u from 'updeep'; +import fp from 'lodash/fp'; + +export default new Updux({ + initial: { + id: 0, + description: "", + done: false, + }, + actions: { + todoDone: null, + }, + mutations: { + todoDone: id => u.if( fp.matches({id}), { done: true }) ) + }, + selectors: { + desc: ({description}) => description, + } +}); + +``` + +### Todos updux + +``` +// dux/todos/index.js + +import { Updux } from 'updux'; +import u from 'updeep'; +import fp from 'lodash/fp'; + +import todo from './todo/index.js'; + +export default new Updux({ + initial: [], + subduxes: { + '*': todoDux + }, + actions: { + addTodoWithId: (description, id) => ({description, id} ) + }, + findSelectors: { + getTodoById: state => id => fp.find({id},state) + }, + mutations: { + addTodoWithId: todo => + todos => [ ...todos, todo ] + } +}); +``` + +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. + +### Main store + +``` +// dux/index.js + +import Updux from 'updux'; + +import todos from './todos'; +import nextId from './next_id'; + +export new Updux({ + subduxes: { + nextId, + todos, + }, + actions: { + addTodo: null + }, + effects: { + addTodo: ({ getState, dispatch }) => next => action => { + const id = getState.getNextId(); + + dispatch.incrementNextId() + + next(action); + + dispatch.addTodoWithId( action.payload, id ); + } + } +}); + +``` + +Tadah! We had to define the `addTodo` effect at the top level as it needs to +access the `getNextId` selector from `nextId` and the `addTodoWithId` +action from the `todos`. + +Note that the `getNextId` selector still gets the +right value; when aggregating subduxes selectors Updux auto-wraps them to +access the right slice of the top object. ``` + diff --git a/src/Updux.original b/src/Updux.original new file mode 100644 index 0000000..586b803 --- /dev/null +++ b/src/Updux.original @@ -0,0 +1,457 @@ +/* TODO change * for leftovers to +, change subscriptions to reactions */ +import moize from 'moize'; +import u from 'updeep'; +import { createStore as reduxCreateStore, applyMiddleware } from 'redux'; +import { get, map, mapValues, merge, difference } from 'lodash-es'; + +import { buildInitial } from './buildInitial/index.js'; +import { buildActions } from './buildActions/index.js'; +import { buildSelectors } from './buildSelectors/index.js'; +import { action } from './actions.js'; +import { buildUpreducer } from './buildUpreducer.js'; +import { + buildMiddleware, + augmentMiddlewareApi, + effectToMiddleware, +} from './buildMiddleware/index.js'; + +import { + AggregateDuxActions, + AggregateDuxState, + Dict, + ItemsOf, + Reducer, + Upreducer, +} from './types.js'; + +type Mutation = (payload:TAction['payload'], action:TAction) => (state: TState) => TState; + +/** + * Configuration object typically passed to the constructor of the class Updux. + */ +export interface UpduxConfig< + TState = any, + TActions = {}, + TSelectors = {}, + TSubduxes = {} +> { + /** + * Local initial state. + * @default {} + */ + initial?: TState; + + /** + * Subduxes to be merged to this dux. + */ + subduxes?: TSubduxes; + + /** + * Local actions. + */ + actions?: TActions; + + /** + * Local selectors. + */ + selectors?: Record; + + /** + * Local mutations + */ + mutations?: Record; + + /** + * Selectors to apply to the mapped subduxes. Only + * applicable if the dux is a mapping dux. + */ + mappedSelectors?: Record; + + /** + * Local effects. + */ + effects?: Record; + + /** + * Local reactions. + */ + reactions?: Function[]; + + /** + * If true, enables mapped reactions. Additionally, it can be + * a reaction function, which will treated as a regular + * reaction for the mapped dux. + */ + mappedReaction?: Function | boolean; + + /** + * Wrapping function for the upreducer to provides full customization. + * @example + * // if an action has the 'dontDoIt' meta flag, don't do anything + * const dux = new Updux({ + * ..., + * upreducerWrapper: (upreducer) => action => { + * if( action?.meta?.dontDoIt ) return state => state; + * return upreducer(action); + * } + * }) + */ + upreducerWrapper?: ( + upreducer: Upreducer< + AggregateDuxState, + ItemsOf> + > + ) => Upreducer< + AggregateDuxState, + ItemsOf> + >; + + middlewareWrapper?: Function; +} + +export class Updux< + TState extends any = {}, + TActions extends object = {}, + TSelectors = {}, + TSubduxes extends object = {} +> { + /** @type { unknown } */ + #initial = {}; + #subduxes = {}; + + /** @type Record */ + #actions = {}; + #selectors = {}; + #mutations = {}; + #effects = []; + #reactions = []; + #mappedSelectors = undefined; + #mappedReaction = undefined; + #upreducerWrapper = undefined; + + #middlewareWrapper = undefined; + + constructor( + config: UpduxConfig + ) { + this.#initial = config.initial ?? {}; + this.#subduxes = config.subduxes ?? {}; + + if (config.subduxes) { + this.#subduxes = mapValues(config.subduxes, (sub) => + sub instanceof Updux ? sub : new Updux(sub) + ); + } + + if (config.actions) { + for (const [type, actionArg] of Object.entries(config.actions)) { + if (typeof actionArg === 'function' && actionArg.type) { + this.#actions[type] = actionArg; + } else { + const args = Array.isArray(actionArg) + ? actionArg + : [actionArg]; + this.#actions[type] = action(type, ...args); + } + } + } + + this.#selectors = config.selectors ?? {}; + this.#mappedSelectors = config.mappedSelectors; + + this.#mutations = config.mutations ?? {}; + + Object.keys(this.#mutations) + .filter((action) => action !== '+') + .filter((action) => !this.actions.hasOwnProperty(action)) + .forEach((action) => { + throw new Error(`action '${action}' is not defined`); + }); + + if (config.effects) { + this.#effects = Object.entries(config.effects); + } + + this.#reactions = config.reactions ?? []; + + this.#mappedReaction = config.mappedReaction; + + this.#upreducerWrapper = config.upreducerWrapper; + + this.#middlewareWrapper = config.middlewareWrapper; + } + + #memoInitial = moize(buildInitial); + #memoActions = moize(buildActions); + #memoSelectors = moize(buildSelectors); + #memoUpreducer = moize(buildUpreducer); + #memoMiddleware = moize(buildMiddleware); + + setMappedSelector(name, f) { + this.#mappedSelectors = { + ...this.#mappedSelectors, + [name]: f, + }; + } + + get middleware() { + return this.#memoMiddleware( + this.#effects, + this.actions, + this.selectors, + this.#subduxes, + this.#middlewareWrapper, + this + ); + } + + setMiddlewareWrapper(wrapper: Function) { + this.#middlewareWrapper = wrapper; + } + + /** @member { unknown } */ + get initial(): AggregateDuxState { + return this.#memoInitial(this.#initial, this.#subduxes); + } + + get actions(): AggregateDuxActions { + return this.#memoActions(this.#actions, this.#subduxes) as any; + } + + get selectors() { + return this.#memoSelectors( + this.#selectors, + this.#mappedSelectors, + this.#subduxes + ); + } + + get subduxes() { return this.#subduxes } + + get upreducer(): Upreducer< + AggregateDuxState, + ItemsOf> + > { + return this.#memoUpreducer( + this.initial, + this.#mutations, + this.#subduxes, + this.#upreducerWrapper + ); + } + + get reducer(): Reducer< + AggregateDuxState, + ItemsOf> + > { + return (state, action) => this.upreducer(action)(state); + } + + addSubscription(subscription) { + this.#reactions = [...this.#reactions, subscription]; + } + + addReaction(reaction) { + this.#reactions = [...this.#reactions, reaction]; + } + + + setAction(type, payloadFunc?: (...args: any) => any) { + const theAction = action(type, payloadFunc); + + this.#actions = { ...this.#actions, [type]: theAction }; + + return theAction; + } + + setSelector(name, func) { + // TODO selector already exists? Complain! + this.#selectors = { + ...this.#selectors, + [name]: func, + }; + return func; + } + + setMutation>(name: TAction, mutation: Mutation, + ReturnType[TAction]>>) { + if (typeof name === 'function') name = name.type; + + this.#mutations = { + ...this.#mutations, + [name]: mutation, + }; + + return mutation; + } + + addEffect(action: TType, effect: E): E { + this.#effects = [...this.#effects, [action, effect]]; + return effect; + } + + augmentMiddlewareApi(api) { + return augmentMiddlewareApi(api, this.actions, this.selectors); + } + + splatSubscriber(store, inner, splatReaction) { + const cache = {}; + + return () => (state, previous, unsub) => { + const cacheKeys = Object.keys(cache); + + const newKeys = difference(Object.keys(state), cacheKeys); + + for (const slice of newKeys) { + let localStore = { + ...store, + getState: () => store.getState()[slice], + }; + + cache[slice] = []; + + if (typeof splatReaction === 'function') { + localStore = { + ...localStore, + ...splatReaction(localStore, slice), + }; + } + + const { unsub, subscriber, subscriberRaw } = + inner.subscribeAll(localStore); + + cache[slice].push({ unsub, subscriber, subscriberRaw }); + subscriber(); + } + + const deletedKeys = difference(cacheKeys, Object.keys(state)); + + for (const deleted of deletedKeys) { + for (const inner of cache[deleted]) { + inner.subscriber(); + inner.unsub(); + } + delete cache[deleted]; + } + }; + } + + subscribeTo(store, subscription, setupArgs = []) { + const localStore = augmentMiddlewareApi( + { + ...store, + subscribe: (subscriber) => + this.subscribeTo(store, () => subscriber), + }, + this.actions, + this.selectors + ); + + const subscriber = subscription(localStore, ...setupArgs); + + let previous; + + const memoSub = () => { + const state = store.getState(); + if (state === previous) return; + let p = previous; + previous = state; + subscriber(state, p, unsub); + }; + + let ret = store.subscribe(memoSub); + const unsub = typeof ret === 'function' ? ret : ret.unsub; + return { + unsub, + subscriber: memoSub, + subscriberRaw: subscriber, + }; + } + + subscribeAll(store) { + let results = this.#reactions.map((sub) => + this.subscribeTo(store, sub) + ); + + for (const subdux in this.#subduxes) { + if (subdux !== '*') { + const localStore = { + ...store, + getState: () => get(store.getState(), subdux), + }; + results.push(this.#subduxes[subdux].subscribeAll(localStore)); + } + } + + if (this.#mappedReaction) { + results.push( + this.subscribeTo( + store, + this.splatSubscriber( + store, + this.#subduxes['*'], + this.#mappedReaction + ) + ) + ); + } + + return { + unsub: () => results.forEach(({ unsub }) => unsub()), + subscriber: () => + results.forEach(({ subscriber }) => subscriber()), + subscriberRaw: (...args) => + results.forEach(({ subscriberRaw }) => + subscriberRaw(...args) + ), + }; + } + + createStore(initial?: unknown, enhancerGenerator?: Function) { + const enhancer = (enhancerGenerator ?? applyMiddleware)( + this.middleware + ); + + const store: { + getState: Function & Record; + dispatch: Function & Record; + selectors: Record; + actions: AggregateDuxActions; + } = reduxCreateStore( + this.reducer as any, + initial ?? this.initial, + enhancer + ) as any; + + store.actions = this.actions; + + store.selectors = this.selectors; + + merge( + store.getState, + mapValues(this.selectors, (selector) => { + return (...args) => { + let result = selector(store.getState()); + + if (typeof result === 'function') return result(...args); + + return result; + }; + }) + ); + + for (const action in this.actions) { + store.dispatch[action] = (...args) => { + return store.dispatch(this.actions[action](...(args as any))); + }; + } + + this.subscribeAll(store); + + return store; + } + + effectToMiddleware(effect) { + return effectToMiddleware(effect, this.actions, this.selectors); + } +}