Merge branch 'actions-subdux' into typescript

This commit is contained in:
Yanick Champoux 2023-03-06 12:16:23 -05:00
commit f5cdc4207a
14 changed files with 147 additions and 36 deletions

View File

@ -3,6 +3,11 @@
version: '3'
tasks:
build: tsc
checks:
deps: [test, build]
test: vitest run src
test:dev: vitest src

6
foo.ts
View File

@ -1,6 +0,0 @@
const foo = <A, B>(x: Partial<{ a: A; b: B }>) => x as B;
const y = foo({
a: 'potato',
b: 2,
});

View File

@ -1,3 +1,4 @@
import * as R from 'remeda';
import {
createStore as reduxCreateStore,
applyMiddleware,
@ -5,7 +6,8 @@ import {
Action,
} from 'redux';
import { configureStore, Reducer, createAction } from '@reduxjs/toolkit';
import { withPayload } from './actions';
import { withPayload } from './actions.js';
import { AggregateActions, Dux, UnionToIntersection } from './types.js';
type ActionCreator = ReturnType<typeof createAction>;
@ -20,6 +22,7 @@ type Mutation<A extends ActionCreator = ActionCreator, S = any> = (
export default class Updux<
T_LocalState = Record<any, any>,
T_LocalActions = {},
SUBDUXES extends Record<string, Dux> = {},
> {
#localInitial: T_LocalState;
#localActions: T_LocalActions;
@ -27,16 +30,28 @@ export default class Updux<
string,
Mutation<ActionCreator, AggregateState<T_LocalState>>
> = {};
#subduxes: SUBDUXES;
#actions: Record<string, ActionCreator>;
constructor(
config: Partial<{
initial: T_LocalState;
actions: T_LocalActions;
subduxes: SUBDUXES;
}>,
) {
// TODO check that we can't alter the initial after the fact
this.#localInitial = config.initial ?? ({} as T_LocalState);
this.#localActions = config.actions ?? ({} as T_LocalActions);
this.#subduxes = config.subduxes ?? ({} as SUBDUXES);
}
get actions(): AggregateActions<T_LocalActions, SUBDUXES> {
return R.mergeAll([
this.#localActions,
...Object.values(this.#subduxes).map(R.pathOr(['actions'], {})),
]) as any;
}
// TODO memoize?
@ -44,10 +59,6 @@ export default class Updux<
return this.#localInitial;
}
get actions() {
return this.#localActions;
}
createStore(
options: Partial<{
initial: T_LocalState;

View File

@ -34,23 +34,6 @@ test('Updux config accepts actions', () => {
});
});
test('subduxes actions', () => {
const foo = new Updux({
actions: {
foo: null,
},
subduxes: {
beta: {
actions: {
bar: null,
},
},
},
});
expect(foo.actions).toHaveProperty('foo');
expect(foo.actions).toHaveProperty('bar');
});
test('throw if double action', () => {
expect(

27
src/actions.test.ts Normal file
View File

@ -0,0 +1,27 @@
import Updux, { createAction } from './index.js';
test('subduxes actions', () => {
const bar = createAction<number>('bar');
const baz = createAction('baz');
const foo = new Updux({
actions: {
bar,
},
subduxes: {
beta: {
actions: {
baz,
},
},
// to check if we can deal with empty actions
gamma: {},
},
});
expect(foo.actions).toHaveProperty('bar');
expect(foo.actions).toHaveProperty('baz');
expect(foo.actions.bar(2)).toHaveProperty('type', 'bar');
expect(foo.actions.baz()).toHaveProperty('type', 'baz');
});

View File

@ -1,5 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
export { createAction } from '@reduxjs/toolkit';
export const withPayload =
<P>() =>
(payload: P) => ({ payload });
(payload: P) => ({ payload });

View File

@ -0,0 +1,48 @@
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(Object.assign(Object.assign({}, 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 Object.assign(Object.assign({}, 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);
}

View File

@ -0,0 +1,20 @@
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) => (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);
}

View File

@ -1,5 +1,5 @@
import Updux from './Updux';
import Updux from './Updux.js';
export { withPayload, createAction } from './actions';
export { withPayload, createAction } from './actions.js';
export default Updux;

21
src/types.ts Normal file
View File

@ -0,0 +1,21 @@
import { Action, ActionCreator } from 'redux';
export type Dux<
STATE = any,
ACTIONS extends Record<string, ActionCreator<string>> = {},
> = Partial<{
initial: STATE;
actions: ACTIONS;
}>;
type ActionsOf<DUX> = DUX extends { actions: infer A } ? A : {};
export type AggregateActions<A, S> = UnionToIntersection<
ActionsOf<S[keyof S]> | A
>;
export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never;

View File

@ -25,9 +25,9 @@
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ES2020" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
"module": "nodenext" /* Specify what module code is generated. */,
"rootDir": "./src" /* Specify the root folder within your source files. */,
"moduleResolution": "nodenext" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
@ -51,7 +51,7 @@
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
@ -78,7 +78,7 @@
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
"strict": false /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */