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); + } +} diff --git a/src/Updux.js b/src/Updux.todo similarity index 97% rename from src/Updux.js rename to src/Updux.todo index d36f395..31e9b04 100644 --- a/src/Updux.js +++ b/src/Updux.todo @@ -247,8 +247,11 @@ export class Updux { return (state, previousState, unsubscribe) => { const gone = { ...cache }; - // TODO assuming object here - for (const key in state) { + const mappedState = Array.isArray(state)? Object.fromEntries( + state.map( s => [ mapper(s), s ] ) + ) : state; + + for (const key in mappedState) { if (cache[key]) { delete gone[key]; } else { diff --git a/src/actions.test.js b/src/actions.test.todo similarity index 100% rename from src/actions.test.js rename to src/actions.test.todo diff --git a/src/actions.js b/src/actions.todo similarity index 100% rename from src/actions.js rename to src/actions.todo diff --git a/src/buildMiddleware/index.ts b/src/buildMiddleware/index.ts new file mode 100644 index 0000000..433c531 --- /dev/null +++ b/src/buildMiddleware/index.ts @@ -0,0 +1,85 @@ +import u from 'updeep'; +import { mapValues, map, get } from 'lodash-es'; + +const middlewareFor = (type, middleware) => (api) => (next) => (action) => { + if (type !== '*' && action.type !== type) return next(action); + + return middleware(api)(next)(action); +}; + +const sliceMw = (slice, mw) => (api) => { + const getSliceState = () => get(api.getState(), slice); + return mw({ ...api, getState: getSliceState }); +}; + +export function augmentMiddlewareApi(api, actions, selectors) { + const getState = () => api.getState(); + const dispatch = (action) => api.dispatch(action); + + Object.assign( + getState, + mapValues(selectors, (selector) => { + return (...args) => { + let result = selector(api.getState()); + + if (typeof result === 'function') return result(...args); + + return result; + }; + }) + ); + + Object.assign( + dispatch, + mapValues(actions, (action) => { + return (...args) => api.dispatch(action(...args)); + }) + ); + + return { + ...api, + getState, + dispatch, + actions, + selectors, + }; +} + +export const effectToMiddleware = (effect, actions, selectors) => { + let mw = effect; + let action = '*'; + + if (Array.isArray(effect)) { + action = effect[0]; + mw = effect[1]; + mw = middlewareFor(action, mw); + } + + return (api) => mw(augmentMiddlewareApi(api, actions, selectors)); +}; + +const composeMw = (mws) => (api) => (original_next) => + mws.reduceRight((next, mw) => mw(api)(next), original_next); + +export function buildMiddleware( + effects = [], + actions = {}, + selectors = {}, + sub = {}, + wrapper = undefined, + dux = undefined, +) { + let inner = map(sub, ({ middleware }, slice) => + slice !== '*' && middleware ? sliceMw(slice, middleware) : undefined + ).filter((x) => x); + + const local = effects.map((effect) => + effectToMiddleware(effect, actions, selectors) + ); + + let mws = [...local, ...inner]; + + if( wrapper ) mws = wrapper(mws,dux); + + return composeMw(mws); +} diff --git a/src/buildSelectors/index.ts b/src/buildSelectors/index.ts new file mode 100644 index 0000000..3b58af7 --- /dev/null +++ b/src/buildSelectors/index.ts @@ -0,0 +1,38 @@ +import { map, mapValues, merge } from 'lodash-es'; + +export function buildSelectors( + localSelectors, + splatSelector = {}, + subduxes = {} +) { + const subSelectors = map(subduxes, ({ selectors }, slice) => { + if (!selectors) return {}; + if (slice === '*') return {}; + + return mapValues( + selectors, + (func: Function) => (state) => func(state[slice]) + ); + }); + + let splat = {}; + + for (const name in splatSelector) { + splat[name] = + (state) => + (...args) => { + const value = splatSelector[name](state)(...args); + + const res = () => value; + return merge( + res, + mapValues( + subduxes['*'].selectors, + (selector) => () => selector(value) + ) + ); + }; + } + + return merge({}, ...subSelectors, localSelectors, splat); +} diff --git a/src/buildUpreducer.js b/src/buildUpreducer.todo similarity index 100% rename from src/buildUpreducer.js rename to src/buildUpreducer.todo diff --git a/src/createStore.test.js b/src/createStore.test.todo similarity index 100% rename from src/createStore.test.js rename to src/createStore.test.todo diff --git a/src/dux-selectors.test.js b/src/dux-selectors.test.todo similarity index 100% rename from src/dux-selectors.test.js rename to src/dux-selectors.test.todo diff --git a/src/index.js b/src/index.todo similarity index 100% rename from src/index.js rename to src/index.todo diff --git a/src/initial.test.js b/src/initial.test.todo similarity index 100% rename from src/initial.test.js rename to src/initial.test.todo diff --git a/src/middleware.test.js b/src/middleware.test.todo similarity index 100% rename from src/middleware.test.js rename to src/middleware.test.todo diff --git a/src/middleware.js b/src/middleware.todo similarity index 100% rename from src/middleware.js rename to src/middleware.todo diff --git a/src/mutations.test.js b/src/mutations.test.todo similarity index 100% rename from src/mutations.test.js rename to src/mutations.test.todo diff --git a/src/reactions.test.js b/src/reactions.test.todo similarity index 100% rename from src/reactions.test.js rename to src/reactions.test.todo diff --git a/src/reducer.test.js b/src/reducer.test.todo similarity index 100% rename from src/reducer.test.js rename to src/reducer.test.todo diff --git a/src/selectors.js b/src/selectors.todo similarity index 100% rename from src/selectors.js rename to src/selectors.todo diff --git a/src/splatReactions.test.js b/src/splatReactions.test.todo similarity index 100% rename from src/splatReactions.test.js rename to src/splatReactions.test.todo diff --git a/src/upreducer.js b/src/upreducer.todo similarity index 100% rename from src/upreducer.js rename to src/upreducer.todo diff --git a/src/utils.js b/src/utils.todo similarity index 100% rename from src/utils.js rename to src/utils.todo