tutorial with schema

This commit is contained in:
Yanick Champoux 2023-09-06 15:09:45 -04:00
parent bc12cf6dcd
commit 76311d31e9
45 changed files with 1871 additions and 1014 deletions

View File

@ -9,7 +9,7 @@ tasks:
build: tsc
checks:
deps: [lint, test, build]
deps: [test, build]
integrate:
deps: [checks]
@ -20,7 +20,7 @@ tasks:
- git checkout {{.PARENT_BRANCH}}
- git weld -
test: vitest run src
test: vitest run src docs/*.ts
test:dev: vitest src
lint:fix:delta:

View File

@ -1,3 +1,3 @@
* [Home](/)
* [ Tutorial ](tutorial.md)
* [ Recipes ](recipes.md)
- [Home](/)
- [ Tutorial ](tutorial.md)
- [ Recipes ](recipes.md)

19
docs/nextId.ts Normal file
View File

@ -0,0 +1,19 @@
/// [dux]
import Updux from '../src/index.js';
import u from '@yanick/updeep-remeda';
const nextIdDux = new Updux({
initialState: 1,
actions: {
incNextId: null,
},
selectors: {
getNextId: state => state
}
});
nextIdDux.addMutation('incNextId', () => id => id + 1)
export default nextIdDux.asDux;
/// [dux]

20
docs/todo.ts Normal file
View File

@ -0,0 +1,20 @@
import Updux from '../src/index.js';
import u from '@yanick/updeep-remeda';
const todoDux = new Updux({
initialState: {
id: 0,
description: "",
done: false,
},
actions: {
todoDone: null,
},
selectors: {
desc: ({ description }) => description,
}
});
todoDux.addMutation('todoDone', () => u({ done: true }));
export default todoDux.asDux;

28
docs/todoList.ts Normal file
View File

@ -0,0 +1,28 @@
import Updux from '../src/index.js';
import nextIdDux from './nextId';
import todosDux from './todos.js';
const todosListDux = new Updux({
subduxes: {
todos: todosDux,
nextId: nextIdDux,
},
actions: {
addTodo: (description: string) => description,
}
});
todosListDux.addEffect(
'addTodo', ({ getState, dispatch }) => next => action => {
const id = getState.getNextId();
dispatch.incNextId();
next(action);
dispatch.addTodoWithId(action.payload, id);
}
);
export default todosListDux;

26
docs/todos.ts Normal file
View File

@ -0,0 +1,26 @@
import Updux from '../src/index.js';
import u from '@yanick/updeep-remeda';
import todoDux from './todo.js';
const todosDux = new Updux({
initialState: [] as (typeof todoDux.initialState)[],
actions: {
addTodoWithId: (description, id) => ({ description, id }),
todoDone: (id: number) => (id),
},
findSelectors: {
getTodoById: state => id => state.find(u.matches({ id }))
}
});
todosDux.addMutation('addTodoWithId', todo => todos => todos.concat({ ...todo, done: false }));
todosDux.addMutation(
'todoDone', (id, action) => u.map(
u.if(u.matches({ id }), todoDux.upreducer(action))
)
)
export default todosDux.asDux;

19
docs/tutorial-1.test.js Normal file
View File

@ -0,0 +1,19 @@
import { test, expect } from 'vitest';
/// [tut1]
import Updux from 'updux';
const todosDux = new Updux({
initialState: {
nextId: 1,
todos: [],
}
});
/// [tut1]
/// [tut2]
const store = todosDux.createStore();
store.getState(); // { nextId: 1, todos: [] }
/// [tut2]
test("basic", () => {
expect(store.getState()).toEqual({
nextId: 1, todos: []
});
});

25
docs/tutorial-1.test.ts Normal file
View File

@ -0,0 +1,25 @@
import { test, expect } from 'vitest';
/// [tut1]
import Updux from 'updux';
const todosDux = new Updux({
initialState: {
nextId: 1,
todos: [],
}
});
/// [tut1]
/// [tut2]
const store = todosDux.createStore();
store.getState(); // { nextId: 1, todos: [] }
/// [tut2]
test("basic", () => {
expect(store.getState()).toEqual({
nextId: 1, todos: []
})
});

View File

@ -0,0 +1,56 @@
import { test, expect } from 'vitest';
/// [actions1]
import Updux, { createAction, withPayload } from 'updux';
const addTodo = createAction('addTodo', withPayload());
const todoDone = createAction('todoDone', withPayload());
const todosDux = new Updux({
initialState: {
nextId: 1,
todos: [],
},
actions: {
addTodo,
todoDone,
}
});
/// [actions1]
/// [actions2]
todosDux.actions.addTodo('write tutorial');
// { type: 'addTodo', payload: 'write tutorial' }
/// [actions2]
test("basic", () => {
expect(todosDux.actions.addTodo('write tutorial')).toEqual({
type: 'addTodo', payload: 'write tutorial'
});
});
/// [addMutation]
todosDux.addMutation(addTodo, (description) => (state) => {
state.todos.unshift({ description, id: state.nextId, done: false });
state.nextId++;
});
const store = todosDux.createStore();
store.dispatch.addTodo('write tutorial');
const state = store.getState();
// {
// nextId: 2,
// todos: [
// {
// description: 'write tutorial',
// done: false,
// id: 1,
// }
// ]
// }
/// [addMutation]
test("addMutation", () => {
expect(state).toEqual({
nextId: 2,
todos: [
{
description: 'write tutorial',
done: false,
id: 1,
}
]
});
});

View File

@ -0,0 +1,71 @@
import { test, expect } from 'vitest';
/// [actions1]
import Updux, { createAction, withPayload } from 'updux';
type TodoId = number;
const addTodo = createAction('addTodo', withPayload<string>());
const todoDone = createAction('todoDone', withPayload<TodoId>());
const todosDux = new Updux({
initialState: {
nextId: 1,
todos: [],
},
actions: {
addTodo,
todoDone,
}
});
/// [actions1]
/// [actions2]
todosDux.actions.addTodo('write tutorial');
// { type: 'addTodo', payload: 'write tutorial' }
/// [actions2]
test("basic", () => {
expect(todosDux.actions.addTodo('write tutorial')).toEqual({
type: 'addTodo', payload: 'write tutorial'
})
});
/// [addMutation]
todosDux.addMutation(addTodo, (description) => ({ todos, nextId }) => {
return {
todos: todos.concat({ description, id: nextId, done: false }),
nextId: 1 + nextId,
}
});
const store = todosDux.createStore();
store.dispatch.addTodo('write tutorial');
const state = store.getState();
// {
// nextId: 2,
// todos: [
// {
// description: 'write tutorial',
// done: false,
// id: 1,
// }
// ]
// }
/// [addMutation]
test("addMutation", () => {
expect(state).toEqual({
nextId: 2,
todos: [
{
description: 'write tutorial',
done: false,
id: 1,
}
]
});
});

View File

@ -1,69 +1,37 @@
import { test, expect } from 'vitest';
import u from 'updeep';
import { action, Updux, dux } from '../src/index.js';
const addTodoWithId = action('addTodoWithId');
const incNextId = action('incNextId');
const addTodo = action('addTodo');
const addTodoEffect = ({ getState, dispatch }) => next => action => {
const id = getState.nextId();
dispatch.incNextId();
next(action);
dispatch.addTodoWithId({ description: action.payload, id });
}
process.env.UPDEEP_MODE = "dangerously_never_freeze";
// [effects-1]
import u from '@yanick/updeep-remeda';
import * as R from 'remeda';
import Updux, { createAction, withPayload } from 'updux';
const addTodoWithId = createAction('addTodoWithId', withPayload());
const incNextId = createAction('incNextId');
const addTodo = createAction('addTodo', withPayload());
const todosDux = new Updux({
initial: { nextId: 1, todos: [] },
initialState: { nextId: 1, todos: [] },
actions: { addTodo, incNextId, addTodoWithId },
selectors: {
nextId: ({nextId}) => nextId,
nextId: ({ nextId }) => nextId,
},
mutations: {
addTodoWithId: (todo) => u({ todos: (todos) => [...todos, todo] }),
incNextId: () => u({ nextId: id => id+1 }),
},
effects: {
'addTodo': addTodoEffect
}
});
todosDux.addMutation(addTodoWithId, (todo) => state => state
// u({
// todos: R.concat([u(todo, { done: false })])
// })
);
todosDux.addMutation(incNextId, () => state => state); //u({ nextId: id => id + 1 }));
// todosDux.addEffect(addTodo, ({ getState, dispatch }) => next => action => {
// const id = getState.nextId();
// dispatch.incNextId();
// next(action);
// dispatch.addTodoWithId({ id, description: action.payload });
// });
const store = todosDux.createStore();
test( "tutorial example", async () => {
store.dispatch.addTodo('Do the thing');
expect( store.getState() ).toMatchObject({
nextId:2, todos: [ { description: 'Do the thing', id: 1 } ]
})
store.dispatch.addTodo('write tutorial');
// [effects-1]
test('basic', () => {
expect(store.getState()).toMatchObject({
nextId: 2,
todos: [{ id: 1, description: 'write tutorial', done: false }]
});
});
test( "catch-all effect", () => {
let seen = [];
const foo = new Updux({
actions: {
one: null,
two: null,
},
effects: {
'*': (api) => next => action => {
seen.push(action.type);
next(action);
}
}
} );
const store = foo.createStore();
store.dispatch.one();
store.dispatch.two();
expect(seen).toEqual([ 'one', 'two' ]);
} )

View File

@ -0,0 +1,63 @@
import { test, expect } from 'vitest';
//process.env.UPDEEP_MODE = "dangerously_never_freeze";
/// [effects-1]
import u from '@yanick/updeep-remeda';
import * as R from 'remeda';
import Updux, { createAction, withPayload } from '../src/index.js';
// 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 'addTodo'.
type TodoId = number;
const addTodoWithId = createAction('addTodoWithId', withPayload<{
id: TodoId, description: string
}>());
const incNextId = createAction('incNextId');
const addTodo = createAction('addTodo', withPayload<string>());
const todosDux = new Updux({
initialState: { nextId: 1, todos: [] },
actions: { addTodo, incNextId, addTodoWithId },
selectors: {
nextId: ({ nextId }: { nextId: number }) => nextId,
},
});
todosDux.addMutation(addTodoWithId, (todo) => u({
todos: R.concat([u(todo, { done: false })])
}));
todosDux.addMutation(
incNextId, () => u({ nextId: id => id + 1 })
);
todosDux.addEffect("addTodo", ({ getState, dispatch }) => next => action => {
const id = getState.nextId();
dispatch.incNextId();
next(action);
dispatch.addTodoWithId(
{ id, description: action.payload }
);
}
)
const store = todosDux.createStore();
store.dispatch.addTodo('write tutorial');
/// [effects-1]
test('basic', () => {
expect(store.getState()).toMatchObject({
nextId: 2,
todos: [{ id: 1, description: 'write tutorial', done: false }]
})
})

View File

@ -0,0 +1,42 @@
import { expectTypeOf } from 'expect-type';
import { test, expect } from 'vitest';
import todoListDux from './todoList.js';
test("basic", () => {
const store = todoListDux.createStore();
store.dispatch.addTodo('write tutorial');
store.dispatch.addTodo('test code snippets');
store.dispatch.todoDone(2);
const s = store.getState();
expectTypeOf(s).toMatchTypeOf<{
nextId: number
}>();
expect(store.getState()).toMatchObject({
todos: [
{ id: 1, done: false },
{ id: 2, done: true }
]
});
expect(todoListDux.schema).toMatchObject({
type: 'object',
properties: {
nextId: { type: 'number', default: 1 },
todos: {
default: [],
type: 'array',
}
},
default: {
nextId: 1,
todos: [],
},
});
});

View File

@ -0,0 +1,78 @@
import { test, expect } from 'vitest';
/// [mono]
import Updux from '../src/index.js';
import u from '@yanick/updeep-remeda';
type TodoId = number;
const todosDux = new Updux({
initialState: {
nextId: 1,
todos: [],
},
actions: {
addTodo: (description: string) => description,
addTodoWithId: (description: string, id: TodoId) => ({ description, id, done: false }),
todoDone: (id: TodoId) => id,
incNextId: null,
},
selectors: {
getTodoById: ({ todos }) => id => todos.find(u.matches({ id })),
getNextId: ({ nextId }) => nextId,
},
});
todosDux.addMutation(
todosDux.actions.addTodoWithId, todo =>
u.updateIn('todos', todos => [...todos, todo]),
);
todosDux.addMutation(
todosDux.actions.incNextId,
() => u({ nextId: x => x + 1 }));
todosDux.addMutation(
todosDux.actions.todoDone, (id) => u.updateIn('todos',
u.map(u.if(u.matches({ id }), { done: true }))
)
);
todosDux.addEffect(
todosDux.actions.addTodo, ({ getState, dispatch }) => next => action => {
const id = getState.getNextId();
dispatch.incNextId();
next(action);
dispatch.addTodoWithId(action.payload, id);
}
);
/// [mono]
test('basic', () => {
const store = todosDux.createStore();
store.dispatch.addTodo('write tutorial');
store.dispatch.addTodo('have fun');
expect(store.getState()).toMatchObject({
nextId: 3,
todos: [
{ id: 1, description: 'write tutorial', done: false },
{ id: 2, description: 'have fun', done: false }
]
});
store.dispatch.todoDone(1);
expect(store.getState()).toMatchObject({
nextId: 3,
todos: [
{ id: 1, description: 'write tutorial', done: true },
{ id: 2, description: 'have fun', done: false },
]
});
})

View File

@ -0,0 +1,42 @@
import { test, expect } from 'vitest';
/// [sel1]
import Updux from 'updux';
const dux = new Updux({
initialState: [] as { id: number, done: boolean }[],
selectors: {
getDone: (state) => state.filter(({ done }) => done),
getById: (state) => (id) => state.find(todo => todo.id === id),
}
});
const state = [{ id: 1, done: true }, { id: 2, done: false }];
dux.selectors.getDone(state); // = [ { id: 1, done: true } ]
dux.selectors.getById(state)(2); // = { id: 2, done: false }
const store = dux.createStore({ preloadedState: state });
store.selectors.getDone(state); // = [ { id: 1, done: true } ]
store.selectors.getById(state)(2); // = { id: 2, done: false }
store.getState.getDone(); // = [ { id: 1, done: true } ]
store.getState.getById(2); // = { id: 2, done: false }
/// [sel1]
test('selectors', () => {
expect(dux.selectors.getDone(state)).toMatchObject([{ id: 1, done: true }]);
expect(dux.selectors.getById(state)(2)).toMatchObject({ id: 2 });
expect(store.selectors.getDone(state)).toMatchObject([{ id: 1, done: true }]);
expect(store.selectors.getById(state)(2)).toMatchObject({ id: 2 });
expect(store.getState()).toMatchObject([
{ id: 1 }, { id: 2 }
]);
expect(store.getState.getDone()).toMatchObject([{ id: 1, done: true }]);
expect(store.getState.getById(2)).toMatchObject({ id: 2 });
});

View File

@ -15,68 +15,42 @@ plain JavaScript.
## Definition of the state
To begin with, let's define that has nothing but an initial state.
```js
import Updux from 'updux';
const todosDux = new Updux({
initial: {
nextId: 1,
todos: [],
}
});
```
[filename](tutorial-1.test.ts ':include :type=code :fragment=tut1')
Congrats! You have written your first Updux object. It
doesn't do a lot, but you can already create a store out of it, and its
initial state will be automatically set:
```js
const store = todosDux.createStore();
console.log(store.getState()); // prints { nextId: 1, todos: [] }
```
[filename](tutorial-1.test.ts ':include :type=code :fragment=tut2')
## Add actions
This is all good, but a little static. Let's add actions!
```js
import { createAction } from 'updux';
This is all good, but very static. Let's add some actions!
const addTodo = createAction('addTodo');
const todoDone = createAction('todoDone');
const todosDux = new Updux({
initial: {
nextId: 1,
todos: [],
},
actions: {
addTodo,
todoDone,
}
});
```
[filename](tutorial-actions.test.ts ':include :type=code :fragment=actions1')
### Accessing actions
Once an action is defined, its creator is accessible via the `actions` accessor.
This is not yet terribly exciting, but it'll get better once we begin using
This is not yet terribly exciting, but it'll get more interesting once we begin using
subduxes.
```js
console.log( todosDux.actions.addTodo('write tutorial') );
// => { type: 'addTodo', payload: 'write tutorial' }
```
[filename](tutorial-actions.test.ts ':include :type=code :fragment=actions2')
### Adding a mutation
Mutations are the reducing functions associated to actions. They
are defined via the `mutation` method:
are defined via the `addMutation` method.
[filename](tutorial-actions.test.ts ':include :type=code :fragment=addMutation')
Note that in the mutation we take the liberty of changing the state directly.
Typically, that'd be a big no-no, but we're safe here because updux wraps all mutations in an immer `produce`.
```js
dux.mutation(addTodo, (state, description) => {
state.todos.unshift({ description, id: state.nextId, done: false });
state.nextId++;
});
```
## Effects
In addition to mutations, Updux also provides action-specific middleware, here
@ -87,93 +61,16 @@ The `getState` and `dispatch` functions are augmented with the dux selectors,
and actions, respectively. The selectors and actions are also available
from the api object.
```js
import u from 'updeep';
import { action, Updux } from 'updux';
[filename](tutorial-effects.test.ts ':include :type=code :fragment=effects-1')
// 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 'addTodo'.
## Selectors
const addTodoWithId = action('addTodoWithId');
const incNextId = action('incNextId');
const addTodo = action('addTodo');
const addTodoEffect = ({ getState, dispatch }) => next => action => {
const id = getState.nextId();
dispatch.incNextId();
next(action);
dispatch.addTodoWithId({ description: action.payload, id });
}
const todosDux = new Updux({
initial: { nextId: 1, todos: [] },
actions: { addTodo, incNextId, addTodoWithId },
selectors: {
nextId: ({nextId}) => nextId,
},
mutations: {
addTodoWithId: (todo) => u({ todos: (todos) => [...todos, todo] }),
incNextId: () => u({ nextId: id => id+1 }),
},
effects: {
'addTodo': addTodoEffect
}
});
const store = todosDux.createStore();
store.dispatch.addTodo('Do the thing');
```
### Catch-all effect
It is possible to have an effect match all actions via the special `*` token.
```
todosUpdux.addEffect('*', () => next => action => {
console.log( 'seeing action fly by:', action );
next(action);
});
```
## Adding selectors
Selectors can be defined to get data derived from the state.
From now you should know the drill: selectors can be defined at construction
time or via `setSelector`.
```
const getTodoById = ({todos}) => targetId => todos.find(({id}) => id === targetId);
const todosUpdux = new Updux({
selectors: {
getTodoById
}
})
```
or
```
todosDux.setSelector('getTodoById', getTodoById);
```
### Accessing selectors
The `getState` method of a dux store is augmented
Selectors can be defined to get data derived from the state.
The `getState` method of a dux store will be augmented
with its selectors, with the first call for the state already
called in for you.
curried for you.
```js
const store = todosDux.createStore();
console.log(
todosUpdux.getState.getTodoById(1)
);
```
[filename](tutorial-selectors.test.ts ':include :type=code :fragment=sel1')
## Subduxes
@ -186,47 +83,7 @@ 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);
}
}
});
```
[filename](tutorial-monolith.test.ts ':include :type=code :fragment=mono')
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
@ -234,120 +91,19 @@ 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,
}
});
```
[filename](nextId.ts ':include :type=code :fragment=dux')
### 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,
}
});
```
[filename](todo.ts ':include :type=code')
### 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.
[filename](todos.ts ':include :type=code')
### 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 );
}
}
});
```
[filename](todoList.ts ':include :type=code')
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`
@ -355,7 +111,7 @@ 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. ```
access the right slice of the top object.
## Reactions

View File

@ -1,9 +1,13 @@
{
"type": "module",
"dependencies": {
"@yanick/updeep-remeda": "^2.1.0",
"@yanick/updeep-remeda": "^2.2.0",
"expect-type": "^0.16.0",
"immer": "^9.0.15",
"json-schema-shorthand": "^2.0.0",
"json-schema-to-ts": "^2.9.2",
"memoize-one": "^6.0.0",
"moize": "^6.1.6",
"redux": "^4.2.0",
"remeda": "^1.0.1",
"updeep": "^1.2.1"
@ -45,6 +49,7 @@
"jsdoc-to-markdown": "^7.1.1",
"prettier": "^2.7.1",
"redux-toolkit": "^1.1.2",
"tsdoc-markdown": "^0.0.4",
"typescript": "^4.9.5",
"vite": "^4.2.1",
"vitest": "0.23.1"

View File

@ -1,81 +1,29 @@
import type { DuxConfig, DuxState } from './types.js';
import u from '@yanick/updeep-remeda';
import moize from 'moize/mjs/index.mjs';
import * as R from 'remeda';
import { expandAction, buildActions, DuxActions } from './actions.js';
import {
createStore as reduxCreateStore,
applyMiddleware,
DeepPartial,
Action,
MiddlewareAPI,
AnyAction,
Middleware,
Dispatch,
} from 'redux';
import {
configureStore,
Reducer,
ActionCreator,
ActionCreatorWithoutPayload,
ActionCreatorWithPreparedPayload,
AnyAction,
configureStore,
EnhancedStore,
} from '@reduxjs/toolkit';
import { AggregateActions, AggregateSelectors, Dux } from './types.js';
import { buildActions } from './buildActions.js';
import { buildInitial, AggregateState } from './initial.js';
import { buildReducer, MutationCase } from './reducer.js';
import { produce } from 'immer';
import { buildReducer } from './reducer.js';
import { buildInitialState } from './initialState.js';
import { buildSelectors, DuxSelectors } from './selectors.js';
import { AugmentedMiddlewareAPI, augmentGetState } from './createStore.js';
import {
augmentGetState,
augmentMiddlewareApi,
buildEffects,
buildEffectsMiddleware,
EffectMiddleware,
} from './effects.js';
import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore.js';
import { produce } from 'immer';
import buildSchema from './schema.js';
type MyActionCreator = { type: string } & ((...args: any) => any);
type XSel<R> = R extends Function ? R : () => R;
type CurriedSelector<S> = S extends (...args: any) => infer R ? XSel<R> : never;
type CurriedSelectors<S> = {
[key in keyof S]: CurriedSelector<S[key]>;
};
type ResolveAction<
ActionType extends string,
ActionArg extends any,
> = ActionArg extends MyActionCreator
? ActionArg
: ActionArg extends (...args: any) => any
? ActionCreatorWithPreparedPayload<
Parameters<ActionArg>,
ReturnType<ActionArg>,
ActionType
>
: ActionCreatorWithoutPayload<ActionType>;
type ResolveActions<
A extends {
[key: string]: any;
},
> = {
[ActionType in keyof A]: ActionType extends string
? ResolveAction<ActionType, A[ActionType]>
: never;
};
type Reaction<S = any, M extends MiddlewareAPI = MiddlewareAPI> = (
api: M,
) => (state: S, previousState: S, unsubscribe: () => void) => any;
type AugmentedMiddlewareAPI<S, A, SELECTORS> = MiddlewareAPI<
Dispatch<AnyAction>,
S
> & {
dispatch: A;
getState: CurriedSelectors<SELECTORS>;
actions: A;
selectors: SELECTORS;
};
export type Mutation<A extends Action<any> = Action<any>, S = any> = (
export type Mutation<A = AnyAction, S = any> = (
payload: A extends {
payload: infer P;
}
@ -84,124 +32,151 @@ export type Mutation<A extends Action<any> = Action<any>, S = any> = (
action: A,
) => (state: S) => S | void;
type SelectorForState<S> = (state: S) => unknown;
type SelectorsForState<S> = {
[key: string]: SelectorForState<S>;
};
export default class Updux<D extends DuxConfig> {
#mutations = [];
export default class Updux<
T_LocalState = Record<any, any>,
T_LocalActions extends {
[actionType: string]: any;
} = {},
T_Subduxes extends Record<string, Dux> = {},
T_LocalSelectors extends SelectorsForState<
AggregateState<T_LocalState, T_Subduxes>
> = {},
> {
#localInitial: T_LocalState;
#localActions: T_LocalActions;
#localMutations: MutationCase[] = [];
#defaultMutation: Omit<MutationCase, 'matcher'>;
#subduxes: T_Subduxes;
constructor(private readonly duxConfig: D) {
// just to warn at creation time if config has issues
this.actions;
}
#name: string;
memoInitialState = moize(buildInitialState, {
maxSize: 1,
});
#actions: AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>;
memoBuildActions = moize(buildActions, {
maxSize: 1,
});
#initialState: AggregateState<T_LocalState, T_Subduxes>;
memoBuildReducer = moize(buildReducer, { maxSize: 1 });
#localSelectors: Record<
string,
(state: AggregateState<T_LocalState, T_Subduxes>) => any
>;
#selectors: any;
memoBuildSelectors = moize(buildSelectors, { maxSize: 1 });
#localEffects: Middleware[] = [];
memoBuildEffects = moize(buildEffects, { maxSize: 1 });
constructor(
config: Partial<{
initialState: T_LocalState;
actions: T_LocalActions;
subduxes: T_Subduxes;
selectors: T_LocalSelectors;
}>,
) {
// TODO check that we can't alter the initialState after the fact
this.#localInitial = config.initialState ?? ({} as T_LocalState);
this.#localActions = config.actions ?? ({} as T_LocalActions);
this.#subduxes = config.subduxes ?? ({} as T_Subduxes);
memoBuildSchema = moize(buildSchema, { maxSize: 1 });
this.#actions = buildActions(this.#localActions, this.#subduxes);
get schema() {
return this.memoBuildSchema(
this.duxConfig.schema,
this.initialState,
this.duxConfig.subduxes,
)
}
this.#initialState = buildInitial(this.#localInitial, this.#subduxes);
this.#localSelectors = config.selectors;
const basedSelectors = R.mergeAll(
Object.entries(this.#subduxes)
.filter(([slice, { selectors }]) => selectors)
.map(([slice, { selectors }]) =>
R.mapValues(selectors, (s) => (state = {}) => {
return s(state?.[slice]);
}),
),
get initialState(): DuxState<D> {
return this.memoInitialState(
this.duxConfig.initialState,
this.duxConfig.subduxes,
);
this.#selectors = R.merge(basedSelectors, this.#localSelectors);
}
get actions() {
return this.#actions;
get actions(): DuxActions<D> {
return this.memoBuildActions(
this.duxConfig.actions,
this.duxConfig.subduxes,
);
}
// TODO memoize?
get initialState() {
return this.#initialState;
toDux() {
return {
initialState: this.initialState,
actions: this.actions,
reducer: this.reducer,
effects: this.effects,
reactions: this.reactions,
selectors: this.selectors,
upreducer: this.upreducer,
schema: this.schema,
};
}
get asDux() {
return this.toDux();
}
get effects() {
return [
...this.#localEffects,
...Object.entries(this.#subduxes).flatMap(
([slice, { effects }]) => {
if (!effects) return [];
return effects.map(
(effect) => (api) =>
effect({
...api,
getState: () => api.getState()[slice],
}),
);
},
),
get foo(): DuxActions<D> {
return true as any;
}
get upreducer() {
const reducer = this.reducer;
return action => state => reducer(state, action);
}
// TODO be smarter with the guard?
addMutation<A extends keyof DuxActions<D>>(
matcher: A,
mutation: Mutation<DuxActions<D>[A] extends (...args: any) => infer P ? P : never, DuxState<D>>,
terminal?: boolean,
);
addMutation<A extends Action<any>>(
matcher: (action: A) => boolean,
mutation: Mutation<A, DuxState<D>>,
terminal?: boolean,
);
addMutation<A extends ActionCreator<any>>(
actionCreator: A,
mutation: Mutation<ReturnType<A>, DuxState<D>>,
terminal?: boolean,
);
addMutation(matcher, mutation, terminal = false) {
if (typeof matcher === 'string') {
if (!this.actions[matcher]) {
throw new Error(`action ${matcher} is unknown`);
}
matcher = this.actions[matcher];
}
if (typeof matcher === 'function' && matcher.match) {
// matcher, matcher man...
matcher = matcher.match;
}
//const immerMutation = (...args) => produce(mutation(...args));
this.#mutations = [
...this.#mutations,
{
terminal,
matcher,
mutation,
},
];
}
get reactions(): any {
return [
...this.#localReactions,
...Object.entries(this.#subduxes).flatMap(
([slice, { reactions }]) =>
reactions.map(
(r) => (api, unsub) =>
r(
{
...api,
getState: () => api.getState()[slice],
},
unsub,
),
),
),
];
#defaultMutation;
addDefaultMutation(
mutation: Mutation<any, DuxState<D>>,
terminal?: boolean,
);
addDefaultMutation(mutation, terminal = false) {
this.#defaultMutation = { terminal, mutation };
}
get reducer() {
return this.memoBuildReducer(
this.initialState,
this.#mutations,
this.#defaultMutation,
this.duxConfig.subduxes,
);
}
get selectors(): DuxSelectors<D> {
return this.memoBuildSelectors(
this.duxConfig.selectors,
this.duxConfig.subduxes,
) as any;
}
createStore(
options: Partial<{
preloadedState: T_LocalState;
preloadedState: DuxState<D>;
}> = {},
) {
const preloadedState: any = options.preloadedState;
): EnhancedStore<DuxState<D>> & AugmentedMiddlewareAPI<D> {
const preloadedState = options.preloadedState;
const effects = buildEffectsMiddleware(
this.effects,
@ -210,10 +185,7 @@ export default class Updux<
);
const store = configureStore({
reducer: this.reducer as Reducer<
AggregateState<T_LocalState, T_Subduxes>,
AnyAction
>,
reducer: this.reducer,
preloadedState,
middleware: [effects],
});
@ -239,104 +211,50 @@ export default class Updux<
(store as any).actions = this.actions;
(store as any).selectors = this.selectors;
return store as ToolkitStore<AggregateState<T_LocalState, T_Subduxes>> &
AugmentedMiddlewareAPI<
AggregateState<T_LocalState, T_Subduxes>,
AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>,
AggregateSelectors<
T_LocalSelectors,
T_Subduxes,
AggregateState<T_LocalState, T_Subduxes>
>
>;
return store as any;
// return store as ToolkitStore<AggregateState<T_LocalState, T_Subduxes>> &
// AugmentedMiddlewareAPI<
// AggregateState<T_LocalState, T_Subduxes>,
// AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>,
// AggregateSelectors<
// T_LocalSelectors,
// T_Subduxes,
// AggregateState<T_LocalState, T_Subduxes>
// >
// >;
}
get selectors(): AggregateSelectors<
T_LocalSelectors,
T_Subduxes,
AggregateState<T_LocalState, T_Subduxes>
> {
return this.#selectors as any;
}
// TODO memoize this sucker
get reducer() {
return buildReducer(
this.initialState,
this.#localMutations,
this.#defaultMutation,
this.#subduxes,
) as any as (
state: undefined | typeof this.initialState,
action: Action,
) => typeof this.initialState;
}
// TODO be smarter with the guard?
addMutation<A extends Action<any>>(
matcher: (action: A) => boolean,
mutation: Mutation<A, AggregateState<T_LocalState, T_Subduxes>>,
terminal?: boolean,
);
addMutation<A extends ActionCreator<any>>(
actionCreator: A,
mutation: Mutation<
ReturnType<A>,
AggregateState<T_LocalState, T_Subduxes>
>,
terminal?: boolean,
);
addMutation(matcher, mutation, terminal = false) {
if (typeof matcher === 'function' && matcher.match) {
// matcher, matcher man...
matcher = matcher.match;
}
const immerMutation = (...args) => produce(mutation(...args));
this.#localMutations.push({
terminal,
matcher,
mutation: immerMutation,
});
}
addDefaultMutation(
mutation: Mutation<
Action<any>,
AggregateState<T_LocalState, T_Subduxes>
>,
terminal = false,
) {
this.#defaultMutation = { mutation, terminal };
}
#effects = [];
addEffect(
actionCreator: AggregateActions<
ResolveActions<T_LocalActions>,
T_Subduxes
>,
effect: EffectMiddleware,
): EffectMiddleware;
actionType: keyof DuxActions<D>,
effect: EffectMiddleware<D>,
): EffectMiddleware<D>;
addEffect(
guardFunc: (
action: AggregateActions<
ResolveActions<T_LocalActions>,
T_Subduxes
>[keyof AggregateActions<
ResolveActions<T_LocalActions>,
T_Subduxes
>],
) => boolean,
effect: EffectMiddleware,
): EffectMiddleware;
addEffect(effect: EffectMiddleware): EffectMiddleware;
actionCreator: { match: (action: any) => boolean },
effect: EffectMiddleware<D>,
): EffectMiddleware<D>;
addEffect(
guardFunc: (action: AnyAction) => boolean,
effect: EffectMiddleware<D>,
): EffectMiddleware<D>;
addEffect(effect: EffectMiddleware<D>): EffectMiddleware<D>;
addEffect(...args) {
let effect;
if (args.length === 1) {
effect = args[0];
} else {
const [actionCreator, originalEffect] = args;
let [actionCreator, originalEffect] = args;
if (typeof actionCreator === 'string') {
if (this.actions[actionCreator]) {
actionCreator = this.actions[actionCreator];
}
else {
throw new Error(`action '${actionCreator}' is unknown`);
}
}
const test = actionCreator.hasOwnProperty('match')
? actionCreator.match
@ -351,33 +269,18 @@ export default class Updux<
};
}
this.#localEffects.push(effect);
this.#effects = [...this.#effects, effect];
return effect;
}
get effectsMiddleware() {
return buildEffectsMiddleware(
this.effects,
this.actions,
this.selectors,
);
get effects() {
return this.memoBuildEffects(this.#effects, this.duxConfig.subduxes);
}
#localReactions: any[] = [];
#reactions = [];
addReaction(
reaction: Reaction<
AggregateState<T_LocalState, T_Subduxes>,
AugmentedMiddlewareAPI<
AggregateState<T_LocalState, T_Subduxes>,
AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>,
AggregateSelectors<
T_LocalSelectors,
T_Subduxes,
AggregateState<T_LocalState, T_Subduxes>
>
>
>,
reaction// :DuxReaction<D>
) {
let previous: any;
@ -392,33 +295,27 @@ export default class Updux<
r(state, p, unsub);
};
};
this.#localReactions.push(memoized);
this.#reactions = [
...this.#reactions, memoized
]
}
// internal method REMOVE
subscribeTo(store, subscription) {
const localStore = augmentMiddlewareApi(
{
...store,
subscribe: (subscriber) => this.subscribeTo(store, subscriber), // TODO not sure
},
this.actions,
this.selectors,
);
const subscriber = subscription(localStore);
let previous;
let unsub;
const memoSub = () => {
const state = store.getState();
if (state === previous) return;
let p = previous;
previous = state;
subscriber(state, p, unsub);
};
return store.subscribe(memoSub);
get reactions() {
return [
...this.#reactions,
...(Object.entries(this.duxConfig.subduxes ?? {}) as any).flatMap(
([slice, { reactions }]) =>
reactions.map(
(r) => (api, unsub) =>
r(
{
...api,
getState: () => api.getState()[slice],
},
unsub,
),
),
),
];
}
}

265
src/Updux.ts.2023-08-18 Normal file
View File

@ -0,0 +1,265 @@
import * as R from 'remeda';
import {
createStore as reduxCreateStore,
applyMiddleware,
DeepPartial,
Action,
MiddlewareAPI,
AnyAction,
Middleware,
Dispatch,
} from 'redux';
import {
configureStore,
Reducer,
ActionCreator,
ActionCreatorWithoutPayload,
ActionCreatorWithPreparedPayload,
} from '@reduxjs/toolkit';
import { AggregateActions, AggregateSelectors, Dux } from './types.js';
import { buildActions } from './buildActions.js';
import { buildInitial, AggregateState } from './initial.js';
import { buildReducer, MutationCase } from './reducer.js';
import {
augmentGetState,
augmentMiddlewareApi,
buildEffectsMiddleware,
EffectMiddleware,
} from './effects.js';
import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore.js';
import { produce } from 'immer';
type MyActionCreator = { type: string } & ((...args: any) => any);
type ResolveAction<
ActionType extends string,
ActionArg extends any,
> = ActionArg extends MyActionCreator
? ActionArg
: ActionArg extends (...args: any) => any
? ActionCreatorWithPreparedPayload<
Parameters<ActionArg>,
ReturnType<ActionArg>,
ActionType
>
: ActionCreatorWithoutPayload<ActionType>;
type ResolveActions<
A extends {
[key: string]: any;
},
> = {
[ActionType in keyof A]: ActionType extends string
? ResolveAction<ActionType, A[ActionType]>
: never;
};
type Reaction<S = any, M extends MiddlewareAPI = MiddlewareAPI> = (
api: M,
) => (state: S, previousState: S, unsubscribe: () => void) => any;
type SelectorForState<S> = (state: S) => unknown;
type SelectorsForState<S> = {
[key: string]: SelectorForState<S>;
};
export default class Updux<
T_LocalState = Record<any, any>,
T_LocalActions extends {
[actionType: string]: any;
} = {},
T_Subduxes extends Record<string, Dux> = {},
T_LocalSelectors extends SelectorsForState<
AggregateState<T_LocalState, T_Subduxes>
> = {},
> {
#localInitial: T_LocalState;
#localActions: T_LocalActions;
#localMutations: MutationCase[] = [];
#defaultMutation: Omit<MutationCase, 'matcher'>;
#subduxes: T_Subduxes;
#name: string;
#actions: AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>;
#initialState: AggregateState<T_LocalState, T_Subduxes>;
#localSelectors: Record<
string,
(state: AggregateState<T_LocalState, T_Subduxes>) => any
>;
#selectors: any;
#localEffects: Middleware[] = [];
constructor(
config: Partial<{
initialState: T_LocalState;
actions: T_LocalActions;
subduxes: T_Subduxes;
selectors: T_LocalSelectors;
}>,
) {
// TODO check that we can't alter the initialState after the fact
this.#localInitial = config.initialState ?? ({} as T_LocalState);
this.#localActions = config.actions ?? ({} as T_LocalActions);
this.#subduxes = config.subduxes ?? ({} as T_Subduxes);
this.#actions = buildActions(this.#localActions, this.#subduxes);
this.#initialState = buildInitial(this.#localInitial, this.#subduxes);
this.#localSelectors = config.selectors;
const basedSelectors = R.mergeAll(
Object.entries(this.#subduxes)
.filter(([slice, { selectors }]) => selectors)
.map(([slice, { selectors }]) =>
R.mapValues(selectors, (s) => (state = {}) => {
return s(state?.[slice]);
}),
),
);
this.#selectors = R.merge(basedSelectors, this.#localSelectors);
}
get actions() {
return this.#actions;
}
// TODO memoize?
get initialState() {
return this.#initialState;
}
createStore(
options: Partial<{
preloadedState: T_LocalState;
}> = {},
) {
const preloadedState: any = options.preloadedState;
const effects = buildEffectsMiddleware(
this.effects,
this.actions,
this.selectors,
);
const store = configureStore({
reducer: this.reducer as Reducer<
AggregateState<T_LocalState, T_Subduxes>,
AnyAction
>,
preloadedState,
middleware: [effects],
});
const dispatch: any = store.dispatch;
for (const a in this.actions) {
dispatch[a] = (...args) => {
const action = (this.actions as any)[a](...args);
dispatch(action);
return action;
};
}
store.getState = augmentGetState(store.getState, this.selectors);
for (const reaction of this.reactions) {
let unsub;
const r = reaction(store);
unsub = store.subscribe(() => r(unsub));
}
(store as any).actions = this.actions;
(store as any).selectors = this.selectors;
return store as ToolkitStore<AggregateState<T_LocalState, T_Subduxes>> &
AugmentedMiddlewareAPI<
AggregateState<T_LocalState, T_Subduxes>,
AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>,
AggregateSelectors<
T_LocalSelectors,
T_Subduxes,
AggregateState<T_LocalState, T_Subduxes>
>
>;
}
get selectors(): AggregateSelectors<
T_LocalSelectors,
T_Subduxes,
AggregateState<T_LocalState, T_Subduxes>
> {
return this.#selectors as any;
}
// TODO memoize this sucker
get reducer() {
return buildReducer(
this.initialState,
this.#localMutations,
this.#defaultMutation,
this.#subduxes,
) as any as (
state: undefined | typeof this.initialState,
action: Action,
) => typeof this.initialState;
}
addDefaultMutation(
mutation: Mutation<
Action<any>,
AggregateState<T_LocalState, T_Subduxes>
>,
terminal = false,
) {
this.#defaultMutation = { mutation, terminal };
}
get effectsMiddleware() {
return buildEffectsMiddleware(
this.effects,
this.actions,
this.selectors,
);
}
#localReactions: any[] = [];
// internal method REMOVE
subscribeTo(store, subscription) {
const localStore = augmentMiddlewareApi(
{
...store,
subscribe: (subscriber) => this.subscribeTo(store, subscriber), // TODO not sure
},
this.actions,
this.selectors,
);
const subscriber = subscription(localStore);
let previous;
let unsub;
const memoSub = () => {
const state = store.getState();
if (state === previous) return;
let p = previous;
previous = state;
subscriber(state, p, unsub);
};
return store.subscribe(memoSub);
}
}

View File

@ -1,4 +1,11 @@
import Updux, { createAction, withPayload } from './index.js';
import Updux from './Updux.js';
import { createAction, withPayload, expandAction } from './actions.js';
import type { ExpandedAction } from './actions.js';
import { expectTypeOf } from 'expect-type';
import {
ActionCreatorWithoutPayload,
ActionCreatorWithPreparedPayload,
} from '@reduxjs/toolkit';
test('basic action', () => {
const foo = createAction(
@ -12,6 +19,132 @@ test('basic action', () => {
thing: 'bar',
},
});
expectTypeOf(foo).parameters.toMatchTypeOf<[string]>();
expectTypeOf(foo).returns.toMatchTypeOf<{
type: 'foo';
payload: { thing: string };
}>();
});
test('Updux config accepts actions', () => {
const foo = new Updux({
actions: {
one: createAction(
'one',
withPayload((x) => ({ x })),
),
two: createAction(
'two',
withPayload((x) => x),
),
},
});
expect(Object.keys(foo.actions)).toHaveLength(2);
expect(foo.actions.one).toBeTypeOf('function');
expect(foo.actions.one('potato')).toEqual({
type: 'one',
payload: {
x: 'potato',
},
});
expectTypeOf(foo.actions.one).toMatchTypeOf<
ActionCreatorWithPreparedPayload<any, any, any, any>
>();
});
describe('expandAction', () => {
test('as-is', () => {
const result = expandAction(
createAction('foo', withPayload<boolean>()),
);
expectTypeOf(result).toMatchTypeOf<
(input: boolean) => { type: 'foo'; payload: boolean }
>();
expect(result(true)).toMatchObject({
type: 'foo',
payload: true,
});
});
test('0', () => {
const result = expandAction(0, 'foo');
expectTypeOf(result).toMatchTypeOf<() => { type: 'foo' }>();
expect(result()).toMatchObject({
type: 'foo',
});
});
test('function', () => {
const result = expandAction((size: number) => ({ size }), 'foo');
expectTypeOf(result).toMatchTypeOf<
() => { type: 'foo'; payload: { size: number } }
>();
expect(result(12)).toMatchObject({
type: 'foo',
payload: {
size: 12,
},
});
});
});
test('action types', () => {
const action1 = createAction('a1', withPayload<number>());
expectTypeOf(
true as any as ExpandedAction<typeof action1, 'a1'>,
).toMatchTypeOf<
ActionCreatorWithPreparedPayload<
[input: number],
number,
'a1',
never,
never
>
>();
const action2 = (input: boolean) => input;
let typed2: ExpandedAction<typeof action2, 'a2'>;
expectTypeOf(typed2).toMatchTypeOf<
ActionCreatorWithPreparedPayload<
[input: boolean],
boolean,
'a2',
never,
never
>
>();
let typed3: ExpandedAction<boolean, 'a3'>;
expectTypeOf(typed3).toMatchTypeOf<ActionCreatorWithoutPayload<'a3'>>();
});
test('action definition shortcut', () => {
const foo = new Updux({
actions: {
foo: 0,
bar: (x: number) => ({ x }),
baz: createAction('baz', withPayload<boolean>()),
},
});
expect(foo.actions.foo()).toEqual({ type: 'foo', payload: undefined });
expect(foo.actions.baz(false)).toEqual({
type: 'baz',
payload: false,
});
expect(foo.actions.bar(2)).toEqual({
type: 'bar',
payload: { x: 2 },
});
expectTypeOf(foo.actions.foo).toMatchTypeOf<ActionCreatorWithoutPayload<'foo'>>();
});
test('subduxes actions', () => {
@ -38,31 +171,10 @@ test('subduxes actions', () => {
expect(foo.actions.bar(2)).toHaveProperty('type', 'bar');
expect(foo.actions.baz()).toHaveProperty('type', 'baz');
});
test('Updux config accepts actions', () => {
const foo = new Updux({
actions: {
one: createAction(
'one',
withPayload((x) => ({ x })),
),
two: createAction(
'two',
withPayload((x) => x),
),
},
});
expect(Object.keys(foo.actions)).toHaveLength(2);
expect(foo.actions.one).toBeTypeOf('function');
expect(foo.actions.one('potato')).toEqual({
type: 'one',
payload: {
x: 'potato',
},
});
expectTypeOf(foo.actions.baz).toMatchTypeOf<
ActionCreatorWithoutPayload<'baz'>
>();
});
test('throw if double action', () => {
@ -100,23 +212,3 @@ test('throw if double action', () => {
}),
).toThrow(/action 'foo' defined both in subduxes 'gamma' and 'beta'/);
});
test('action definition shortcut', () => {
const foo = new Updux({
actions: {
foo: 0,
bar: (x: number) => ({ x }),
baz: createAction('baz', withPayload<boolean>()),
},
});
expect(foo.actions.foo()).toEqual({ type: 'foo', payload: undefined });
expect(foo.actions.baz(false)).toEqual({
type: 'baz',
payload: false,
});
expect(foo.actions.bar(2)).toEqual({
type: 'bar',
payload: { x: 2 },
});
});

View File

@ -1,29 +1,107 @@
import { createAction } from '@reduxjs/toolkit';
import {
ActionCreator,
ActionCreatorWithoutPayload,
createAction,
} from '@reduxjs/toolkit';
export { createAction } from '@reduxjs/toolkit';
import { ActionCreatorWithPreparedPayload } from '@reduxjs/toolkit';
interface WithPayload {
<P>(): (input: P) => { payload: P };
<P, A extends any[]>(prepare: (...args: A) => P): (...input: A) => {
payload: P;
};
import type {
FromSchema,
SubduxesState,
DuxSchema,
DuxConfig,
UnionToIntersection,
} from './types.js';
import * as R from 'remeda';
export type DuxActions<D> = (D extends { actions: infer A }
? {
[key in keyof A]: key extends string
? ExpandedAction<A[key], key>
: never;
}
: {}) &
UnionToIntersection<
D extends { subduxes: infer S } ? DuxActions<S[keyof S]> : {}
>;
export type ExpandedAction<P, T extends string> = P extends (...a: any[]) => {
type: string;
}
? P
: P extends (...a: any[]) => any
? ActionCreatorWithPreparedPayload<
Parameters<P>,
ReturnType<P>,
T,
never,
never
>
: ActionCreatorWithoutPayload<T>;
export type DuxState<D> = (D extends { schema: Record<string, any> }
? FromSchema<DuxSchema<D>>
: D extends { initialState: infer INITIAL_STATE }
? INITIAL_STATE
: {}) &
(D extends { subduxes: Record<string, DuxConfig> }
? SubduxesState<D>
: unknown);
export function withPayload<P>(): (input: P) => { payload: P };
export function withPayload<P, A extends any[]>(
prepare: (...args: A) => P,
): (...input: A) => { payload: P };
export function withPayload(prepare = (input) => input) {
return (...input) => ({
payload: prepare.apply(null, input),
});
}
export const withPayload: WithPayload = ((prepare) =>
(...input) => ({
payload: prepare ? prepare(...input) : input[0],
})) as any;
export function expandAction(prepare, actionType?: string) {
if (typeof prepare === 'function' && prepare.type) return prepare;
const id = (x) => x;
export const createPayloadAction = <
P extends any = any,
T extends string = string,
F extends (...args: any[]) => P = (input: P) => P,
>(
type: T,
prepare?: F,
) =>
createAction(
type,
withPayload<ReturnType<F>, Parameters<F>>(prepare ?? (id as any)),
);
if (typeof prepare === 'function') {
return createAction(actionType, withPayload(prepare));
}
if (actionType) {
return createAction(actionType);
}
}
export function buildActions(localActions = {}, subduxes = {}) {
localActions = R.mapValues(localActions, expandAction);
let actions: Record<string, string> = {};
for (const slice in subduxes) {
const subdux = subduxes[slice].actions;
if (!subdux) continue;
for (const a in subdux) {
if (actions[a] && subduxes[actions[a]].actions[a] !== subdux[a]) {
throw new Error(
`action '${a}' defined both in subduxes '${actions[a]}' and '${slice}'`,
);
}
actions[a] = slice;
}
}
for (const a in localActions) {
if (actions[a]) {
throw new Error(
`action '${a}' defined both locally and in subdux '${actions[a]}'`,
);
}
}
return R.mergeAll([
localActions,
...Object.values(subduxes).map(R.pathOr<any, any>(['actions'], {})),
]) as any;
}

View File

@ -1,45 +0,0 @@
import { createAction } from '@reduxjs/toolkit';
import * as R from 'remeda';
import { withPayload } from './actions.js';
function resolveActions(configActions) {
return R.mapValues(configActions, (prepare, type: string) => {
if (typeof prepare === 'function' && prepare.type) return prepare;
return createAction(type, withPayload(prepare));
});
}
export function buildActions(localActions, subduxes) {
localActions = resolveActions(localActions);
let actions: Record<string, string> = {};
for (const slice in subduxes) {
const subdux = subduxes[slice].actions;
if (!subdux) continue;
for (const a in subdux) {
if (actions[a] && subduxes[actions[a]].actions[a] !== subdux[a]) {
throw new Error(
`action '${a}' defined both in subduxes '${actions[a]}' and '${slice}'`,
);
}
actions[a] = slice;
}
}
for (const a in localActions) {
if (actions[a]) {
throw new Error(
`action '${a}' defined both locally and in subdux '${actions[a]}'`,
);
}
}
return R.mergeAll([
localActions,
...Object.values(subduxes).map(R.pathOr<any, any>(['actions'], {})),
]) as any;
}

View File

@ -1,21 +0,0 @@
import { test, expect } from 'vitest';
import { buildInitial } from './initial.js';
test('basic', () => {
expect(
buildInitial(
{ a: 1 },
{ b: { initialState: { c: 2 } }, d: { initialState: 'e' } },
),
).toEqual({
a: 1,
b: { c: 2 },
d: 'e',
});
});
test('throw if subduxes and initialState is not an object', () => {
expect(() => {
buildInitial(3, { bar: 'foo' });
}).toThrow();
});

67
src/createStore.test.ts Normal file
View File

@ -0,0 +1,67 @@
import { createAction } from '@reduxjs/toolkit';
import { expectTypeOf } from 'expect-type';
import { test, expect } from 'vitest';
import Updux from './Updux.js';
const dux = new Updux({
initialState: 'a',
actions: {
action1: 0,
},
selectors: {
double: (x: string) => x + x,
},
});
dux.addMutation(dux.actions.action1, () => (state) => 'mutation1');
test('createStore', () => {
expect(dux.createStore().getState()).toEqual('a');
expect(dux.createStore({ preloadedState: 'b' }).getState()).toEqual('b');
});
test('augmentGetState', () => {
const store = dux.createStore();
expect(store.getState()).toEqual('a');
expectTypeOf(store.getState()).toMatchTypeOf<string>();
expectTypeOf(store.getState.double()).toMatchTypeOf<string>();
expect(store.getState.double()).toEqual('aa');
expect(store.actions.action1).toBeTypeOf('function');
expect(store.dispatch.action1()).toMatchObject({ type: 'action1' });
expect(store.getState.double()).toEqual('mutation1mutation1');
// selectors
expect(store.selectors.double).toBeTypeOf('function');
});
test('mutations of subduxes', () => {
const incr = createAction('incr');
const subdux1 = new Updux({
actions: { incr },
initialState: 0,
});
subdux1.addMutation(incr, () => (state) => state + 1);
const dux = new Updux({
subduxes: {
subdux1: subdux1.asDux,
},
});
const store = dux.createStore();
expect(store.getState()).toMatchObject({ subdux1: 0 });
store.dispatch.incr();
expect(store.getState()).toMatchObject({ subdux1: 1 });
store.dispatch.incr();
expect(store.getState()).toMatchObject({ subdux1: 2 });
});

33
src/createStore.ts Normal file
View File

@ -0,0 +1,33 @@
import { AnyAction, Dispatch, MiddlewareAPI } from '@reduxjs/toolkit';
import { DuxActions } from './actions.js';
import { DuxSelectors } from './selectors.js';
import { DuxState } from './types.js';
type XSel<R> = R extends Function ? R : () => R;
type CurriedSelector<S> = S extends (...args: any) => infer R ? XSel<R> : never;
type CurriedSelectors<S> = {
[key in keyof S]: CurriedSelector<S[key]>;
};
export type AugmentedMiddlewareAPI<D> = MiddlewareAPI<
Dispatch<AnyAction>,
DuxState<D>
> & {
dispatch: DuxActions<D>;
getState: CurriedSelectors<DuxSelectors<D>>;
actions: DuxActions<D>;
selectors: DuxSelectors<D>;
};
export function augmentGetState(originalGetState, selectors) {
const getState = () => originalGetState();
for (const s in selectors) {
getState[s] = (...args) => {
let result = selectors[s](originalGetState());
if (typeof result === 'function') return result(...args);
return result;
};
}
return getState;
}

View File

@ -1,5 +1,14 @@
import { test, expect } from 'vitest';
import { expectTypeOf } from 'expect-type';
import Updux from './Updux.js';
import { buildEffectsMiddleware } from './effects.js';
import Updux, { createAction } from './index.js';
import { createAction } from './index.js';
test('addEffect', () => {
const dux = new Updux({});
dux.addEffect((api) => (next) => (action) => { });
});
test('buildEffectsMiddleware', () => {
let seen = 0;
@ -39,7 +48,7 @@ test('buildEffectsMiddleware', () => {
expect(seen).toEqual(0);
const dispatch = vi.fn();
mw({ getState: () => 'the state', dispatch })(() => {})({
mw({ getState: () => 'the state', dispatch })(() => { })({
type: 'noop',
});
expect(seen).toEqual(1);
@ -135,8 +144,8 @@ test('addEffect with actionCreator', () => {
test('addEffect with function', () => {
const dux = new Updux({
actions: {
foo: () => {},
bar: () => {},
foo: () => { },
bar: () => { },
},
});
@ -158,5 +167,32 @@ test('addEffect with function', () => {
expect(spy).toHaveBeenCalled();
});
test('catchall addEffect', () => {
const dux = new Updux({
initialState: {
a: 1
}
});
const spy = vi.fn();
dux.addEffect((api) => next => action => {
expectTypeOf(api.getState()).toMatchTypeOf<{
a: number;
}>();
spy();
next(action);
});
const store = dux.createStore();
expect(spy).not.toHaveBeenCalled();
store.dispatch({ type: 'noop' });
expect(spy).toHaveBeenCalled();
});
// TODO subdux effects
// TODO allow to subscribe / unsubscribe effects?

View File

@ -1,46 +1,32 @@
import { AnyAction } from '@reduxjs/toolkit';
import { MiddlewareAPI } from '@reduxjs/toolkit';
import { Dispatch } from '@reduxjs/toolkit';
import { MiddlewareAPI } from '@reduxjs/toolkit';
import { AugmentedMiddlewareAPI, augmentGetState } from './createStore.js';
export interface EffectMiddleware<S = any, D extends Dispatch = Dispatch> {
(api: MiddlewareAPI<D, S>): (
//const composeMw = (mws) => (api) => (originalNext) =>
// mws.reduceRight((next, mw) => mw(api)(next), originalNext);
export interface EffectMiddleware<D> {
(api: AugmentedMiddlewareAPI<D>): (
next: Dispatch<AnyAction>,
) => (action: AnyAction) => any;
}
const composeMw = (mws) => (api) => (originalNext) =>
mws.reduceRight((next, mw) => mw(api)(next), originalNext);
export const augmentGetState = (originalGetState, selectors) => {
const getState = () => originalGetState();
for (const s in selectors) {
getState[s] = (...args) => {
let result = selectors[s](originalGetState());
if (typeof result === 'function') return result(...args);
return result;
};
}
return getState;
};
const augmentDispatch = (originalDispatch, actions) => {
const dispatch = (action) => originalDispatch(action);
for (const a in actions) {
dispatch[a] = (...args) => dispatch(actions[a](...args));
}
return dispatch;
};
export const augmentMiddlewareApi = (api, actions, selectors) => {
return {
...api,
getState: augmentGetState(api.getState, selectors),
dispatch: augmentDispatch(api.dispatch, actions),
actions,
selectors,
};
};
export function buildEffects(localEffects, subduxes = {}) {
return [
...localEffects,
...(Object.entries(subduxes) as any).flatMap(([slice, { effects }]) => {
if (!effects) return [];
return effects.map(
(effect) => (api) =>
effect({
...api,
getState: () => api.getState()[slice],
}),
);
}),
];
}
export function buildEffectsMiddleware(
effects = [],
@ -57,3 +43,22 @@ export function buildEffectsMiddleware(
};
};
}
export const augmentMiddlewareApi = (api, actions, selectors) => {
return {
...api,
getState: augmentGetState(api.getState, selectors),
dispatch: augmentDispatch(api.dispatch, actions),
actions,
selectors,
};
};
const augmentDispatch = (originalDispatch, actions) => {
const dispatch = (action) => originalDispatch(action);
for (const a in actions) {
dispatch[a] = (...args) => dispatch(actions[a](...args));
}
return dispatch;
};

View File

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

View File

@ -1,26 +0,0 @@
import u from '@yanick/updeep-remeda';
import * as R from 'remeda';
type SubduxState<S> = 'initialState' extends keyof S ? S['initialState'] : {};
export type AggregateState<LOCAL, SUBDUXES extends Record<any, any>> = LOCAL &
(keyof SUBDUXES extends never
? {}
: {
[Slice in keyof SUBDUXES]: Slice extends string
? SubduxState<SUBDUXES[Slice]>
: never;
});
export function buildInitial(localInitial, subduxes) {
if (Object.keys(subduxes).length > 0 && typeof localInitial !== 'object') {
throw new Error(
"can't have subduxes when the initialState value is not an object",
);
}
return u(
localInitial,
R.mapValues(subduxes, R.pathOr(['initialState'], {})),
);
}

View File

@ -1,29 +1,78 @@
import { expectType } from './tutorial.test.js';
import { expectTypeOf } from 'expect-type';
import { buildInitialState } from './initialState.js';
import Updux from './Updux.js';
test('default', () => {
const dux = new Updux({});
expect(dux.initialState).toBeTypeOf('object');
expect(dux.initialState).toEqual({});
expectTypeOf(dux.initialState).toEqualTypeOf<{}>();
});
test('number', () => {
const dux = new Updux({ initialState: 3 });
expect(dux.initialState).toBeTypeOf('number');
expect(dux.initialState).toEqual(3);
expectTypeOf(dux.initialState).toEqualTypeOf<number>();
});
test('single dux', () => {
const foo = new Updux({
initialState: { a: 1 },
});
expect(foo.initialState).toEqual({ a: 1 });
expectTypeOf(foo.initialState).toEqualTypeOf<{ a: number }>();
});
test('no initialState for subdux', () => {
const subduxes = {
bar: new Updux({}).toDux(),
baz: new Updux({ initialState: 'potato' }).toDux(),
};
const dux = new Updux({
subduxes,
}).toDux();
expectTypeOf(dux.initialState).toEqualTypeOf<{
bar: {};
baz: string;
}>();
expect(dux.initialState).toEqual({ bar: {}, baz: 'potato' });
});
test('basic', () => {
expect(
buildInitialState(
{ a: 1 },
{ b: { initialState: { c: 2 } }, d: { initialState: 'e' } },
),
).toEqual({
a: 1,
b: { c: 2 },
d: 'e',
});
});
test('throw if subduxes and initialState is not an object', () => {
expect(() => {
buildInitialState(3, { bar: 'foo' });
}).toThrow();
});
const bar = new Updux({ initialState: 123 });
const foo = new Updux({
initialState: { root: 'abc' },
subduxes: {
bar,
bar: bar.asDux,
},
});
test('default', () => {
const { initialState } = new Updux({});
expect(initialState).toBeTypeOf('object');
expect(initialState).toEqual({});
});
test('number', () => {
const { initialState } = new Updux({ initialState: 3 });
expect(initialState).toBeTypeOf('number');
expect(initialState).toEqual(3);
});
test('initialState to createStore', () => {
const initialState = {
a: 1,
@ -42,14 +91,6 @@ test('initialState to createStore', () => {
});
});
test('single dux', () => {
const foo = new Updux({
initialState: { a: 1 },
});
expect(foo.initialState).toEqual({ a: 1 });
});
// TODO add 'check for no todo eslint rule'
test('initialState value', () => {
expect(foo.initialState).toEqual({
@ -57,27 +98,12 @@ test('initialState value', () => {
bar: 123,
});
expectType<{
expectTypeOf(foo.initialState.bar).toMatchTypeOf<number>();
expectTypeOf(foo.initialState).toMatchTypeOf<{
root: string;
bar: number;
}>(foo.initialState);
});
test('no initialState', () => {
const dux = new Updux({});
expectType<{}>(dux.initialState);
expect(dux.initialState).toEqual({});
});
test('no initialState for subdux', () => {
const dux = new Updux({
subduxes: {
bar: new Updux({}),
baz: new Updux({ initialState: 'potato' }),
},
});
expectType<{ bar: {}; baz: string }>(dux.initialState);
expect(dux.initialState).toEqual({ bar: {}, baz: 'potato' });
}>();
});
test.todo('splat initialState', async () => {

16
src/initialState.ts Normal file
View File

@ -0,0 +1,16 @@
import u from '@yanick/updeep-remeda';
import * as R from 'remeda';
export function buildInitialState(localInitialState, subduxes) {
let state = localInitialState ?? {};
if (subduxes) {
if (typeof state !== 'object') {
throw new Error('root initial state is not an object');
}
state = u(state, R.mapValues(subduxes, R.prop('initialState')));
}
return state;
}

View File

@ -1,12 +1,13 @@
import { createAction } from '@reduxjs/toolkit';
import { expectTypeOf } from 'expect-type';
import { test, expect } from 'vitest';
import Updux, { createAction } from './index.js';
import Updux from './Updux.js';
test('set a mutation', () => {
const dux = new Updux({
initialState: 'potato',
actions: {
foo: (x) => ({ x }),
foo: (x: string) => ({ x }),
bar: 0,
},
});
@ -14,6 +15,8 @@ test('set a mutation', () => {
let didIt = false;
dux.addMutation(dux.actions.foo, (payload, action) => () => {
expectTypeOf(payload).toMatchTypeOf<{ x: string }>();
didIt = true;
expect(payload).toEqual({ x: 'hello ' });
expect(action).toEqual(dux.actions.foo('hello '));
@ -78,3 +81,21 @@ test('mutation of a subdux', () => {
expect(foo.reducer(undefined, baz())).toHaveProperty('bar', 1);
expect(foo.reducer(undefined, stopit())).toHaveProperty('bar', 0);
});
test('actionType as string', () => {
const dux = new Updux({
actions: {
doIt: (id: number) => id,
},
});
dux.addMutation('doIt', (payload) => (state) => {
expectTypeOf(payload).toMatchTypeOf<number>();
return state;
});
expect(() => {
// @ts-ignore
dux.addMutation('unknown', () => (x) => x);
}).toThrow();
});

View File

@ -46,11 +46,11 @@ test('subdux reactions', () => {
},
});
const foo = new Updux({ actions: { notInBar: 0 }, subduxes: { bar } });
// TODO immer that stuff
bar.addMutation(foo.actions.inc, () => (state) => state + 1);
bar.addMutation(foo.actions.reset, () => (state) => 0);
bar.addMutation(bar.actions.inc, () => (state) => {
return state + 1
});
bar.addMutation(bar.actions.reset, () => (state) => 0);
let seen = 0;
bar.addReaction((api) => (state, _previous, unsubscribe) => {
@ -62,6 +62,11 @@ test('subdux reactions', () => {
api.dispatch.reset();
});
const foo = new Updux({
actions: { notInBar: 0 },
subduxes: { bar: bar.asDux },
});
const store = foo.createStore();
store.dispatch.inc();
@ -69,7 +74,9 @@ test('subdux reactions', () => {
expect(store.getState()).toEqual({ bar: 1 });
expect(store.getState.getIt()).toEqual(1);
store.dispatch.inc();
expect(seen).toEqual(2);
store.dispatch.inc();
expect(seen).toEqual(3);
expect(store.getState.getIt()).toEqual(0); // we've been reset
@ -78,5 +85,6 @@ test('subdux reactions', () => {
store.dispatch.inc();
store.dispatch.inc();
expect(seen).toEqual(3);
expect(store.getState.getIt()).toEqual(4); // we've unsubscribed
});

View File

@ -1,4 +1,6 @@
import { createAction } from '@reduxjs/toolkit';
import { test, expect } from 'vitest';
import { withPayload } from './actions.js';
import { buildReducer } from './reducer.js';
import Updux from './Updux.js';
@ -22,10 +24,70 @@ test('buildReducer, mutation', () => {
expect(reducer(undefined, { type: 'inc' })).toEqual(2);
});
test.todo('basic reducer', () => {
const dux = new Updux({ initialState: { a: 3 } });
test('basic reducer', () => {
const dux = new Updux({
initialState: { a: 3 },
actions: {
add: (x: number) => x,
},
});
dux.addMutation(dux.actions.add, (incr) => (state) => ({
a: state.a + incr,
}));
expect(dux.reducer).toBeTypeOf('function');
expect(dux.reducer({ a: 1 }, { type: 'foo' })).toMatchObject({ a: 1 }); // noop
expect(dux.reducer({ a: 1 }, { type: 'noop' })).toMatchObject({ a: 1 }); // noop
expect(dux.reducer({ a: 1 }, dux.actions.add(2))).toMatchObject({ a: 3 });
});
test('defaultMutation', () => {
const dux = new Updux({
initialState: { a: 0, b: 0 },
actions: {
add: (x: number) => x,
},
});
dux.addMutation(dux.actions.add, (incr) => (state) => ({
...state,
a: state.a + incr,
}));
dux.addDefaultMutation((payload) => (state) => ({
...state,
b: state.b + 1,
}));
expect(dux.reducer({ a: 0, b: 0 }, { type: 'noop' })).toMatchObject({
a: 0,
b: 1,
}); // noop catches the default mutation
expect(dux.reducer({ a: 1, b: 0 }, dux.actions.add(2))).toMatchObject({
a: 3,
b: 0,
});
});
test('subduxes mutations', () => {
const sub1 = new Updux({
initialState: 0,
actions: {
sub1action: 0,
},
});
sub1.addMutation(sub1.actions.sub1action, () => (state) => state + 1);
const dux = new Updux({
subduxes: {
sub1: sub1.asDux,
},
});
expect(dux.reducer(undefined, dux.actions.sub1action())).toMatchObject({
sub1: 1,
});
});

View File

@ -1,10 +1,8 @@
import { Action, ActionCreator, createAction } from '@reduxjs/toolkit';
import { BaseActionCreator } from '@reduxjs/toolkit/dist/createAction.js';
import { Action } from '@reduxjs/toolkit';
import * as R from 'remeda';
import { Dux } from './types.js';
import { DuxConfig } from './types.js';
import { Mutation } from './Updux.js';
import u from '@yanick/updeep-remeda';
import { produce } from 'immer';
export type MutationCase = {
matcher: (action: Action) => boolean;
@ -13,10 +11,10 @@ export type MutationCase = {
};
export function buildReducer(
initialStateState: any,
initialStateState: unknown,
mutations: MutationCase[] = [],
defaultMutation?: Omit<MutationCase, 'matcher'>,
subduxes: Record<string, Dux> = {},
subduxes: Record<string, DuxConfig> = {},
) {
const subReducers = R.mapValues(subduxes, R.prop('reducer'));
@ -24,72 +22,42 @@ export function buildReducer(
// TODO defaultMutation
//
const reducer = (state = initialStateState, action: Action) => {
const orig = state;
if (!action?.type)
throw new Error('upreducer called with a bad action');
if (!action?.type) throw new Error('reducer called with a bad action');
let terminal = false;
let didSomething = false;
let active = mutations.filter(({ matcher }) => matcher(action));
mutations
.filter(({ matcher }) => matcher(action))
.forEach(({ mutation, terminal: t }) => {
if (t) terminal = true;
didSomething = true;
state = produce(
state,
mutation((action as any).payload, action),
);
});
if (active.length === 0 && defaultMutation)
active.push(defaultMutation as any);
if (!didSomething && defaultMutation) {
if (defaultMutation.terminal) terminal = true;
state = defaultMutation.mutation(
(action as any).payload,
action,
)(state);
if (
!active.some(R.prop<any, any>('terminal')) &&
Object.values(subReducers).length > 0
) {
active.push({
mutation: (payload, action) => (state) => {
return u(
state,
R.mapValues(
subReducers,
(reducer, slice) => (state) => {
return (reducer as any)(state, action);
},
),
);
},
} as any);
}
if (!terminal && Object.keys(subduxes).length > 0) {
// subduxes
state = u.update(
state,
R.mapValues(subReducers, (reducer, slice) =>
(reducer as any)(state[slice], action),
),
);
}
return state;
// frozen objects don't play well with immer
// if (Object.isFrozen(state)) {
// state = { ...(state as any) };
// }
return active.reduce(
(state, { mutation }) =>
mutation((action as any).payload, action)(state),
state,
);
};
return reducer;
/*
if (subReducers) {
if (subduxes['*']) {
newState = u.updateIn(
'*',
subduxes['*'].upreducer(action),
newState,
);
} else {
const update = mapValues(subReducers, (upReducer) =>
upReducer(action),
);
newState = u(update, newState);
}
}
const a = mutations[action.type] || mutations['+'];
if (!a) return newState;
return a(action.payload, action)(newState);
};
return wrapper ? wrapper(upreducer) : upreducer;
*/
}

31
src/schema.test.ts Normal file
View File

@ -0,0 +1,31 @@
import { test, expect } from 'vitest';
import { expectTypeOf } from 'expect-type';
import Updux from './Updux.js';
test('default schema', () => {
const dux = new Updux({});
expect(dux.schema).toMatchObject({
type: 'object',
});
});
test('basic schema', () => {
const dux = new Updux({
schema: { type: 'number' },
});
expect(dux.schema).toMatchObject({
type: 'number',
});
});
test('schema default inherits from initial state', () => {
const dux = new Updux({
schema: { type: 'number' },
initialState: 8,
});
expect(dux.schema.default).toEqual(8);
});

36
src/schema.ts Normal file
View File

@ -0,0 +1,36 @@
import u from '@yanick/updeep-remeda';
import * as R from 'remeda';
export default function buildSchema(
schema: any = {},
initialState = undefined,
subduxes = {},
) {
if (typeof initialState !== 'undefined')
schema = u(schema, { default: u.constant(initialState) });
if (!schema.type) {
schema = u(schema, {
type: Array.isArray(schema.default)
? 'array'
: typeof schema.default,
});
}
if (schema.type === 'object') {
// TODO will break with objects and arrays
schema = u(schema, {
properties: R.mapValues(schema.default, (v) => ({
type: typeof v,
})),
});
}
if (Object.keys(subduxes).length > 0) {
schema = u(schema, {
properties: R.mapValues(subduxes, R.prop('schema')),
});
}
return schema;
}

View File

@ -2,7 +2,7 @@ import { test, expect } from 'vitest';
import Updux, { createAction } from './index.js';
test('basic selectors', () => {
describe('basic selectors', () => {
type State = { x: number };
const foo = new Updux({
@ -10,7 +10,7 @@ test('basic selectors', () => {
x: 1,
},
selectors: {
getX: ({ x }: State) => x,
getX: ({ x }) => x,
},
subduxes: {
bar: new Updux({
@ -19,8 +19,8 @@ test('basic selectors', () => {
getY: ({ y }: { y: number }) => y,
getYPlus:
({ y }) =>
(incr: number) =>
(y + incr) as number,
(incr: number) =>
(y + incr) as number,
},
}),
},
@ -31,12 +31,16 @@ test('basic selectors', () => {
bar: { y: 3 },
};
expect(foo.selectors.getY(sample)).toBe(3);
expect(foo.selectors.getX(sample)).toBe(4);
test('updux selectors', () => {
expect(foo.selectors.getX(sample)).toBe(4);
expect(foo.selectors.getY(sample)).toBe(3);
expect(foo.selectors.getYPlus(sample)(3)).toBe(6);
expect(foo.selectors.getYPlus(sample)(3)).toBe(6);
});
const store = foo.createStore();
expect(store.getState.getY()).toBe(2);
expect(store.getState.getYPlus(3)).toBe(5);
test.todo('store selectors', () => {
const store = foo.createStore();
expect(store.getState.getY()).toBe(2);
expect(store.getState.getYPlus(3)).toBe(5);
});
});

35
src/selectors.ts Normal file
View File

@ -0,0 +1,35 @@
import * as R from 'remeda';
import { DuxState, UnionToIntersection } from './types.js';
type RebaseSelectors<SLICE, DUX> = DUX extends { selectors: infer S }
? {
[key in keyof S]: RebaseSelector<SLICE, S[key]>;
}
: never;
type RebaseSelector<SLICE, S> = SLICE extends string
? S extends (state: infer STATE) => infer R
? (state: Record<SLICE, STATE>) => R
: never
: never;
type Values<X> = X[keyof X];
export type DuxSelectors<D> = (D extends { selectors: infer S } ? S : {}) &
(D extends { subduxes: infer SUB }
? Values<{
[key in keyof SUB]: RebaseSelectors<key, SUB[key]>;
}>
: {});
export function buildSelectors(localSelectors = {}, subduxes = {}) {
const subSelectors = (Object.entries(subduxes) as any).map(
([slice, { selectors }]) =>
R.mapValues(
selectors,
(subSelect) => (state) => subSelect(state[slice]),
),
);
return R.mergeAll([localSelectors, ...subSelectors]);
}

View File

@ -1,7 +1,6 @@
import Updux, { createAction, withPayload } from './index.js';
import u from '@yanick/updeep-remeda';
export const expectType = <T>(value: T) => value;
import { expectTypeOf } from 'expect-type';
test('initialState state', () => {
const initialState = {
@ -12,10 +11,10 @@ test('initialState state', () => {
initialState,
});
expectType<{
expectTypeOf(dux.initialState).toMatchTypeOf<{
next_id: number;
todos: unknown[];
}>(dux.initialState);
}>();
expect(dux.initialState).toEqual(initialState);

11
src/types.test.ts Normal file
View File

@ -0,0 +1,11 @@
import { test } from 'vitest';
import { expectTypeOf } from 'expect-type';
import { DuxState } from './types.js';
test('duxstate basic', () => {
const x = {
initialState: 'potato',
};
expectTypeOf(true as any as DuxState<typeof x>).toEqualTypeOf<string>();
});

View File

@ -1,46 +1,39 @@
import { Action, ActionCreator, Middleware, Reducer } from 'redux';
export type Dux<
STATE = any,
ACTIONS extends Record<string, ActionCreator<string>> = {},
> = Partial<{
initialState: STATE;
actions: ACTIONS;
selectors: Record<string, (state: STATE) => any>;
reducer: (
state: STATE,
action: ReturnType<ACTIONS[keyof ACTIONS]>,
) => STATE;
effects: Middleware[];
reactions: ((...args: any[]) => void)[];
}>;
type ActionsOf<DUX> = DUX extends { actions: infer A } ? A : {};
type SelectorsOf<DUX> = DUX extends { selectors: infer A } ? A : {};
export type AggregateActions<A, S> = UnionToIntersection<
ActionsOf<S[keyof S]> | A
>;
type BaseSelector<F extends (...args: any) => any, STATE> = (
state: STATE,
) => ReturnType<F>;
type BaseSelectors<S extends Record<string, any>, STATE> = {
[key in keyof S]: BaseSelector<S[key], STATE>;
};
export type AggregateSelectors<
S extends Record<string, (...args: any) => any>,
SUBS extends Record<string, Dux>,
STATE = {},
> = BaseSelectors<
UnionToIntersection<SelectorsOf<SUBS[keyof SUBS]> | S>,
STATE
>;
import type { FromSchema } from 'json-schema-to-ts';
import Updux from './Updux.js';
export type { FromSchema } from 'json-schema-to-ts';
export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never;
export type DuxConfig = Partial<{
initialState: any;
schema: Record<string, any>;
actions: Record<string, any>;
subduxes: Record<string, DuxConfig>;
reducer: unknown;
selectors: Record<string, any>;
}>;
export type DuxSchema<D extends DuxConfig> = D extends { schema: infer S }
? S
: unknown;
type SubduxesOrBust<S, K> = K extends never ? 'potato' : S;
export type SubduxesState<D> = D extends {
subduxes: infer S;
}
? S extends never
? unknown
: {
[key in keyof S]: DuxState<S[key]>;
}
: unknown;
export type DuxState<D> = (D extends { initialState: infer INITIAL_STATE }
? INITIAL_STATE
: {}) &
(D extends { subduxes: any } ? SubduxesState<D> : unknown);

46
src/types.ts.2023-08-18 Normal file
View File

@ -0,0 +1,46 @@
import { Action, ActionCreator, Middleware, Reducer } from 'redux';
export type Dux<
STATE = any,
ACTIONS extends Record<string, ActionCreator<string>> = {},
> = Partial<{
initialState: STATE;
actions: ACTIONS;
selectors: Record<string, (state: STATE) => any>;
reducer: (
state: STATE,
action: ReturnType<ACTIONS[keyof ACTIONS]>,
) => STATE;
effects: Middleware[];
reactions: ((...args: any[]) => void)[];
}>;
type ActionsOf<DUX> = DUX extends { actions: infer A } ? A : {};
type SelectorsOf<DUX> = DUX extends { selectors: infer A } ? A : {};
export type AggregateActions<A, S> = UnionToIntersection<
ActionsOf<S[keyof S]> | A
>;
type BaseSelector<F extends (...args: any) => any, STATE> = (
state: STATE,
) => ReturnType<F>;
type BaseSelectors<S extends Record<string, any>, STATE> = {
[key in keyof S]: BaseSelector<S[key], STATE>;
};
export type AggregateSelectors<
S extends Record<string, (...args: any) => any>,
SUBS extends Record<string, Dux>,
STATE = {},
> = BaseSelectors<
UnionToIntersection<SelectorsOf<SUBS[keyof SUBS]> | S>,
STATE
>;
export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never;

View File

@ -1,4 +1,6 @@
{
"include": ["src/**.ts"],
"exclude": ["docs/**.ts"],
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */