Merge branch 'tutorial-with-schema'

This commit is contained in:
Yanick Champoux 2023-09-06 15:10:16 -04:00
commit 7e8c2ef171
45 changed files with 1871 additions and 1014 deletions

View File

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

View File

@ -1,3 +1,3 @@
* [Home](/) - [Home](/)
* [ Tutorial ](tutorial.md) - [ Tutorial ](tutorial.md)
* [ Recipes ](recipes.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 { test, expect } from 'vitest';
process.env.UPDEEP_MODE = "dangerously_never_freeze";
import u from 'updeep'; // [effects-1]
import { action, Updux, dux } from '../src/index.js'; import u from '@yanick/updeep-remeda';
import * as R from 'remeda';
const addTodoWithId = action('addTodoWithId'); import Updux, { createAction, withPayload } from 'updux';
const incNextId = action('incNextId'); const addTodoWithId = createAction('addTodoWithId', withPayload());
const addTodo = action('addTodo'); const incNextId = createAction('incNextId');
const addTodo = createAction('addTodo', withPayload());
const addTodoEffect = ({ getState, dispatch }) => next => action => {
const id = getState.nextId();
dispatch.incNextId();
next(action);
dispatch.addTodoWithId({ description: action.payload, id });
}
const todosDux = new Updux({ const todosDux = new Updux({
initial: { nextId: 1, todos: [] }, initialState: { nextId: 1, todos: [] },
actions: { addTodo, incNextId, addTodoWithId }, actions: { addTodo, incNextId, addTodoWithId },
selectors: { 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(); const store = todosDux.createStore();
store.dispatch.addTodo('write tutorial');
test( "tutorial example", async () => { // [effects-1]
store.dispatch.addTodo('Do the thing'); test('basic', () => {
expect(store.getState()).toMatchObject({
expect( store.getState() ).toMatchObject({ nextId: 2,
nextId:2, todos: [ { description: 'Do the thing', id: 1 } ] 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 ## Definition of the state
To begin with, let's define that has nothing but an initial state. To begin with, let's define that has nothing but an initial state.
```js
import Updux from 'updux';
const todosDux = new Updux({ [filename](tutorial-1.test.ts ':include :type=code :fragment=tut1')
initial: {
nextId: 1,
todos: [],
}
});
```
Congrats! You have written your first Updux object. It 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 doesn't do a lot, but you can already create a store out of it, and its
initial state will be automatically set: initial state will be automatically set:
```js [filename](tutorial-1.test.ts ':include :type=code :fragment=tut2')
const store = todosDux.createStore();
console.log(store.getState()); // prints { nextId: 1, todos: [] }
```
## Add actions ## Add actions
This is all good, but a little static. Let's add actions! This is all good, but very static. Let's add some actions!
```js
import { createAction } from 'updux';
const addTodo = createAction('addTodo');
const todoDone = createAction('todoDone');
const todosDux = new Updux({ [filename](tutorial-actions.test.ts ':include :type=code :fragment=actions1')
initial: {
nextId: 1,
todos: [],
},
actions: {
addTodo,
todoDone,
}
});
```
### Accessing actions ### Accessing actions
Once an action is defined, its creator is accessible via the `actions` accessor. Once an action is defined, its creator is accessible via the `actions` accessor.
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. subduxes.
```js
console.log( todosDux.actions.addTodo('write tutorial') ); [filename](tutorial-actions.test.ts ':include :type=code :fragment=actions2')
// => { type: 'addTodo', payload: 'write tutorial' }
```
### Adding a mutation ### Adding a mutation
Mutations are the reducing functions associated to actions. They 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 ## Effects
In addition to mutations, Updux also provides action-specific middleware, here 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 and actions, respectively. The selectors and actions are also available
from the api object. from the api object.
```js [filename](tutorial-effects.test.ts ':include :type=code :fragment=effects-1')
import u from 'updeep';
import { action, Updux } from 'updux';
// we want to decouple the increment of next_id and the creation of ## Selectors
// a new todo. So let's use a new version of the action 'addTodo'.
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. Selectors can be defined to get data derived from the state.
From now you should know the drill: selectors can be defined at construction The `getState` method of a dux store will be augmented
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
with its selectors, with the first call for the state already with its selectors, with the first call for the state already
called in for you. curried for you.
```js [filename](tutorial-selectors.test.ts ':include :type=code :fragment=sel1')
const store = todosDux.createStore();
console.log(
todosUpdux.getState.getTodoById(1)
);
```
## Subduxes ## 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 the global state. This is better understood by working out an example, so
let's recap on the Todos dux we have so far: let's recap on the Todos dux we have so far:
```js [filename](tutorial-monolith.test.ts ':include :type=code :fragment=mono')
import Updux from 'updux';
import u from 'updeep';
import fp from 'lodash/fp';
const todosDux = new Updux({
initial: {
nextId: 1,
todos: [],
},
actions: {
addTodo: null,
addTodoWithId: (description, id) => ({description, id, done: false}),
todoDone: null,
incNextId: null,
},
selectors: {
getTodoById: ({todos}) => id => fp.find({id},todos)
},
mutations: {
addTodoWithId: todo =>
u.updateIn( 'todos', todos => [ ...todos, todo] ),
incrementNextId: () => u({ nextId: fp.add(1) }),
todoDone: (id) => u.updateIn('todos',
u.map( u.if( fp.matches({id}), todo => u({done: true}, todo) ) )
),
},
effects: {
addTodo: ({ getState, dispatch }) => next => action => {
const { nextId: id } = getState();
dispatch.incNextId();
next(action);
dispatch.addTodoWithId(action.payload, id);
}
}
});
```
This store has two main components: the `nextId`, and the `todos` collection. 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 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 ### NextId dux
``` [filename](nextId.ts ':include :type=code :fragment=dux')
// dux/nextId.js
import { Updux } from 'updux';
import u from 'updeep';
export default new Updux({
initial: 1,
actions: {
incrementNextId: null,
},
selectors: {
getNextId: state => state
},
mutations: {
incrementNextId: () => state => state + 1,
}
});
```
### Todo updux ### Todo updux
``` [filename](todo.ts ':include :type=code')
// dux/todos/todo/index.ts
import { Updux } from 'updux';
import u from 'updeep';
import fp from 'lodash/fp';
export default new Updux({
initial: {
id: 0,
description: "",
done: false,
},
actions: {
todoDone: null,
},
mutations: {
todoDone: id => u.if( fp.matches({id}), { done: true }) )
},
selectors: {
desc: ({description}) => description,
}
});
```
### Todos updux ### Todos updux
``` [filename](todos.ts ':include :type=code')
// dux/todos/index.js
import { Updux } from 'updux';
import u from 'updeep';
import fp from 'lodash/fp';
import todo from './todo/index.js';
export default new Updux({
initial: [],
subduxes: {
'*': todoDux
},
actions: {
addTodoWithId: (description, id) => ({description, id} )
},
findSelectors: {
getTodoById: state => id => fp.find({id},state)
},
mutations: {
addTodoWithId: todo =>
todos => [ ...todos, todo ]
}
});
```
Note the special '\*' subdux key used here. This
allows the updux to map every item present in its
state to a `todo` updux. See [this recipe](/recipes?id=mapping-a-mutation-to-all-values-of-a-state) for details.
### Main store ### Main store
``` [filename](todoList.ts ':include :type=code')
// dux/index.js
import Updux from 'updux';
import todos from './todos';
import nextId from './next_id';
export new Updux({
subduxes: {
nextId,
todos,
},
actions: {
addTodo: null
},
effects: {
addTodo: ({ getState, dispatch }) => next => action => {
const id = getState.getNextId();
dispatch.incrementNextId()
next(action);
dispatch.addTodoWithId( action.payload, id );
}
}
});
```
Tadah! We had to define the `addTodo` effect at the top level as it needs to 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` 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 Note that the `getNextId` selector still gets the
right value; when aggregating subduxes selectors Updux auto-wraps them to 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 ## Reactions

View File

@ -1,9 +1,13 @@
{ {
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@yanick/updeep-remeda": "^2.1.0", "@yanick/updeep-remeda": "^2.2.0",
"expect-type": "^0.16.0",
"immer": "^9.0.15", "immer": "^9.0.15",
"json-schema-shorthand": "^2.0.0", "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", "redux": "^4.2.0",
"remeda": "^1.0.1", "remeda": "^1.0.1",
"updeep": "^1.2.1" "updeep": "^1.2.1"
@ -45,6 +49,7 @@
"jsdoc-to-markdown": "^7.1.1", "jsdoc-to-markdown": "^7.1.1",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"redux-toolkit": "^1.1.2", "redux-toolkit": "^1.1.2",
"tsdoc-markdown": "^0.0.4",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"vite": "^4.2.1", "vite": "^4.2.1",
"vitest": "0.23.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 * as R from 'remeda';
import { expandAction, buildActions, DuxActions } from './actions.js';
import { import {
createStore as reduxCreateStore,
applyMiddleware,
DeepPartial,
Action, Action,
MiddlewareAPI,
AnyAction,
Middleware,
Dispatch,
} from 'redux';
import {
configureStore,
Reducer,
ActionCreator, ActionCreator,
ActionCreatorWithoutPayload, AnyAction,
ActionCreatorWithPreparedPayload, configureStore,
EnhancedStore,
} from '@reduxjs/toolkit'; } from '@reduxjs/toolkit';
import { AggregateActions, AggregateSelectors, Dux } from './types.js'; import { produce } from 'immer';
import { buildActions } from './buildActions.js'; import { buildReducer } from './reducer.js';
import { buildInitial, AggregateState } from './initial.js'; import { buildInitialState } from './initialState.js';
import { buildReducer, MutationCase } from './reducer.js'; import { buildSelectors, DuxSelectors } from './selectors.js';
import { AugmentedMiddlewareAPI, augmentGetState } from './createStore.js';
import { import {
augmentGetState,
augmentMiddlewareApi, augmentMiddlewareApi,
buildEffects,
buildEffectsMiddleware, buildEffectsMiddleware,
EffectMiddleware, EffectMiddleware,
} from './effects.js'; } from './effects.js';
import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore.js'; import buildSchema from './schema.js';
import { produce } from 'immer';
type MyActionCreator = { type: string } & ((...args: any) => any); export type Mutation<A = AnyAction, S = 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> = (
payload: A extends { payload: A extends {
payload: infer P; payload: infer P;
} }
@ -84,124 +32,151 @@ export type Mutation<A extends Action<any> = Action<any>, S = any> = (
action: A, action: A,
) => (state: S) => S | void; ) => (state: S) => S | void;
type SelectorForState<S> = (state: S) => unknown; export default class Updux<D extends DuxConfig> {
type SelectorsForState<S> = { #mutations = [];
[key: string]: SelectorForState<S>;
};
export default class Updux< constructor(private readonly duxConfig: D) {
T_LocalState = Record<any, any>, // just to warn at creation time if config has issues
T_LocalActions extends { this.actions;
[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; 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< memoBuildSelectors = moize(buildSelectors, { maxSize: 1 });
string,
(state: AggregateState<T_LocalState, T_Subduxes>) => any
>;
#selectors: any;
#localEffects: Middleware[] = []; memoBuildEffects = moize(buildEffects, { maxSize: 1 });
constructor( memoBuildSchema = moize(buildSchema, { maxSize: 1 });
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); get schema() {
return this.memoBuildSchema(
this.duxConfig.schema,
this.initialState,
this.duxConfig.subduxes,
)
}
this.#initialState = buildInitial(this.#localInitial, this.#subduxes); get initialState(): DuxState<D> {
this.#localSelectors = config.selectors; return this.memoInitialState(
this.duxConfig.initialState,
const basedSelectors = R.mergeAll( this.duxConfig.subduxes,
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() { get actions(): DuxActions<D> {
return this.#actions; return this.memoBuildActions(
} this.duxConfig.actions,
this.duxConfig.subduxes,
// TODO memoize?
get initialState() {
return this.#initialState;
}
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 reactions(): any { toDux() {
return [ return {
...this.#localReactions, initialState: this.initialState,
...Object.entries(this.#subduxes).flatMap( actions: this.actions,
([slice, { reactions }]) => reducer: this.reducer,
reactions.map( effects: this.effects,
(r) => (api, unsub) => reactions: this.reactions,
r( selectors: this.selectors,
upreducer: this.upreducer,
schema: this.schema,
};
}
get asDux() {
return this.toDux();
}
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,
{ {
...api, terminal,
getState: () => api.getState()[slice], matcher,
mutation,
}, },
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( createStore(
options: Partial<{ options: Partial<{
preloadedState: T_LocalState; preloadedState: DuxState<D>;
}> = {}, }> = {},
) { ): EnhancedStore<DuxState<D>> & AugmentedMiddlewareAPI<D> {
const preloadedState: any = options.preloadedState; const preloadedState = options.preloadedState;
const effects = buildEffectsMiddleware( const effects = buildEffectsMiddleware(
this.effects, this.effects,
@ -210,10 +185,7 @@ export default class Updux<
); );
const store = configureStore({ const store = configureStore({
reducer: this.reducer as Reducer< reducer: this.reducer,
AggregateState<T_LocalState, T_Subduxes>,
AnyAction
>,
preloadedState, preloadedState,
middleware: [effects], middleware: [effects],
}); });
@ -239,104 +211,50 @@ export default class Updux<
(store as any).actions = this.actions; (store as any).actions = this.actions;
(store as any).selectors = this.selectors; (store as any).selectors = this.selectors;
return store as ToolkitStore<AggregateState<T_LocalState, T_Subduxes>> & return store as any;
AugmentedMiddlewareAPI<
AggregateState<T_LocalState, T_Subduxes>, // return store as ToolkitStore<AggregateState<T_LocalState, T_Subduxes>> &
AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>, // AugmentedMiddlewareAPI<
AggregateSelectors< // AggregateState<T_LocalState, T_Subduxes>,
T_LocalSelectors, // AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>,
T_Subduxes, // AggregateSelectors<
AggregateState<T_LocalState, T_Subduxes> // T_LocalSelectors,
> // T_Subduxes,
>; // AggregateState<T_LocalState, T_Subduxes>
// >
// >;
} }
get selectors(): AggregateSelectors< #effects = [];
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 };
}
addEffect( addEffect(
actionCreator: AggregateActions< actionType: keyof DuxActions<D>,
ResolveActions<T_LocalActions>, effect: EffectMiddleware<D>,
T_Subduxes ): EffectMiddleware<D>;
>,
effect: EffectMiddleware,
): EffectMiddleware;
addEffect( addEffect(
guardFunc: ( actionCreator: { match: (action: any) => boolean },
action: AggregateActions< effect: EffectMiddleware<D>,
ResolveActions<T_LocalActions>, ): EffectMiddleware<D>;
T_Subduxes addEffect(
>[keyof AggregateActions< guardFunc: (action: AnyAction) => boolean,
ResolveActions<T_LocalActions>, effect: EffectMiddleware<D>,
T_Subduxes ): EffectMiddleware<D>;
>], addEffect(effect: EffectMiddleware<D>): EffectMiddleware<D>;
) => boolean,
effect: EffectMiddleware,
): EffectMiddleware;
addEffect(effect: EffectMiddleware): EffectMiddleware;
addEffect(...args) { addEffect(...args) {
let effect; let effect;
if (args.length === 1) { if (args.length === 1) {
effect = args[0]; effect = args[0];
} else { } 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') const test = actionCreator.hasOwnProperty('match')
? actionCreator.match ? actionCreator.match
@ -351,33 +269,18 @@ export default class Updux<
}; };
} }
this.#localEffects.push(effect); this.#effects = [...this.#effects, effect];
return effect; return effect;
} }
get effectsMiddleware() { get effects() {
return buildEffectsMiddleware( return this.memoBuildEffects(this.#effects, this.duxConfig.subduxes);
this.effects,
this.actions,
this.selectors,
);
} }
#localReactions: any[] = []; #reactions = [];
addReaction( addReaction(
reaction: Reaction< reaction// :DuxReaction<D>
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>
>
>
>,
) { ) {
let previous: any; let previous: any;
@ -392,33 +295,27 @@ export default class Updux<
r(state, p, unsub); r(state, p, unsub);
}; };
}; };
this.#localReactions.push(memoized); this.#reactions = [
...this.#reactions, memoized
]
} }
// internal method REMOVE get reactions() {
subscribeTo(store, subscription) { return [
const localStore = augmentMiddlewareApi( ...this.#reactions,
...(Object.entries(this.duxConfig.subduxes ?? {}) as any).flatMap(
([slice, { reactions }]) =>
reactions.map(
(r) => (api, unsub) =>
r(
{ {
...store, ...api,
subscribe: (subscriber) => this.subscribeTo(store, subscriber), // TODO not sure getState: () => api.getState()[slice],
}, },
this.actions, unsub,
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);
} }
} }

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', () => { test('basic action', () => {
const foo = createAction( const foo = createAction(
@ -12,6 +19,132 @@ test('basic action', () => {
thing: 'bar', 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', () => { test('subduxes actions', () => {
@ -38,31 +171,10 @@ test('subduxes actions', () => {
expect(foo.actions.bar(2)).toHaveProperty('type', 'bar'); expect(foo.actions.bar(2)).toHaveProperty('type', 'bar');
expect(foo.actions.baz()).toHaveProperty('type', 'baz'); expect(foo.actions.baz()).toHaveProperty('type', 'baz');
});
test('Updux config accepts actions', () => { expectTypeOf(foo.actions.baz).toMatchTypeOf<
const foo = new Updux({ ActionCreatorWithoutPayload<'baz'>
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',
},
});
}); });
test('throw if double action', () => { test('throw if double action', () => {
@ -100,23 +212,3 @@ test('throw if double action', () => {
}), }),
).toThrow(/action 'foo' defined both in subduxes 'gamma' and 'beta'/); ).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'; export { createAction } from '@reduxjs/toolkit';
import { ActionCreatorWithPreparedPayload } from '@reduxjs/toolkit';
interface WithPayload { import type {
<P>(): (input: P) => { payload: P }; FromSchema,
<P, A extends any[]>(prepare: (...args: A) => P): (...input: A) => { SubduxesState,
payload: P; 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) => export function expandAction(prepare, actionType?: string) {
(...input) => ({ if (typeof prepare === 'function' && prepare.type) return prepare;
payload: prepare ? prepare(...input) : input[0],
})) as any;
const id = (x) => x; if (typeof prepare === 'function') {
export const createPayloadAction = < return createAction(actionType, withPayload(prepare));
P extends any = any, }
T extends string = string,
F extends (...args: any[]) => P = (input: P) => P, if (actionType) {
>( return createAction(actionType);
type: T, }
prepare?: F, }
) =>
createAction( export function buildActions(localActions = {}, subduxes = {}) {
type, localActions = R.mapValues(localActions, expandAction);
withPayload<ReturnType<F>, Parameters<F>>(prepare ?? (id as any)),
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 { 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', () => { test('buildEffectsMiddleware', () => {
let seen = 0; let seen = 0;
@ -39,7 +48,7 @@ test('buildEffectsMiddleware', () => {
expect(seen).toEqual(0); expect(seen).toEqual(0);
const dispatch = vi.fn(); const dispatch = vi.fn();
mw({ getState: () => 'the state', dispatch })(() => {})({ mw({ getState: () => 'the state', dispatch })(() => { })({
type: 'noop', type: 'noop',
}); });
expect(seen).toEqual(1); expect(seen).toEqual(1);
@ -135,8 +144,8 @@ test('addEffect with actionCreator', () => {
test('addEffect with function', () => { test('addEffect with function', () => {
const dux = new Updux({ const dux = new Updux({
actions: { actions: {
foo: () => {}, foo: () => { },
bar: () => {}, bar: () => { },
}, },
}); });
@ -158,5 +167,32 @@ test('addEffect with function', () => {
expect(spy).toHaveBeenCalled(); 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 subdux effects
// TODO allow to subscribe / unsubscribe effects? // TODO allow to subscribe / unsubscribe effects?

View File

@ -1,46 +1,32 @@
import { AnyAction } from '@reduxjs/toolkit'; import { AnyAction } from '@reduxjs/toolkit';
import { MiddlewareAPI } from '@reduxjs/toolkit';
import { Dispatch } 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> { //const composeMw = (mws) => (api) => (originalNext) =>
(api: MiddlewareAPI<D, S>): ( // mws.reduceRight((next, mw) => mw(api)(next), originalNext);
export interface EffectMiddleware<D> {
(api: AugmentedMiddlewareAPI<D>): (
next: Dispatch<AnyAction>, next: Dispatch<AnyAction>,
) => (action: AnyAction) => any; ) => (action: AnyAction) => any;
} }
const composeMw = (mws) => (api) => (originalNext) => export function buildEffects(localEffects, subduxes = {}) {
mws.reduceRight((next, mw) => mw(api)(next), originalNext); return [
...localEffects,
export const augmentGetState = (originalGetState, selectors) => { ...(Object.entries(subduxes) as any).flatMap(([slice, { effects }]) => {
const getState = () => originalGetState(); if (!effects) return [];
for (const s in selectors) { return effects.map(
getState[s] = (...args) => { (effect) => (api) =>
let result = selectors[s](originalGetState()); effect({
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, ...api,
getState: augmentGetState(api.getState, selectors), getState: () => api.getState()[slice],
dispatch: augmentDispatch(api.dispatch, actions), }),
actions, );
selectors, }),
}; ];
}; }
export function buildEffectsMiddleware( export function buildEffectsMiddleware(
effects = [], 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'; import Updux from './Updux.js';
export { createAction } from '@reduxjs/toolkit';
export { withPayload, createAction, createPayloadAction } from './actions.js';
export default Updux; 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'; 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 bar = new Updux({ initialState: 123 });
const foo = new Updux({ const foo = new Updux({
initialState: { root: 'abc' }, initialState: { root: 'abc' },
subduxes: { 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', () => { test('initialState to createStore', () => {
const initialState = { const initialState = {
a: 1, 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' // TODO add 'check for no todo eslint rule'
test('initialState value', () => { test('initialState value', () => {
expect(foo.initialState).toEqual({ expect(foo.initialState).toEqual({
@ -57,27 +98,12 @@ test('initialState value', () => {
bar: 123, bar: 123,
}); });
expectType<{ expectTypeOf(foo.initialState.bar).toMatchTypeOf<number>();
expectTypeOf(foo.initialState).toMatchTypeOf<{
root: string; root: string;
bar: number; 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 () => { 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 { test, expect } from 'vitest';
import Updux from './Updux.js';
import Updux, { createAction } from './index.js';
test('set a mutation', () => { test('set a mutation', () => {
const dux = new Updux({ const dux = new Updux({
initialState: 'potato', initialState: 'potato',
actions: { actions: {
foo: (x) => ({ x }), foo: (x: string) => ({ x }),
bar: 0, bar: 0,
}, },
}); });
@ -14,6 +15,8 @@ test('set a mutation', () => {
let didIt = false; let didIt = false;
dux.addMutation(dux.actions.foo, (payload, action) => () => { dux.addMutation(dux.actions.foo, (payload, action) => () => {
expectTypeOf(payload).toMatchTypeOf<{ x: string }>();
didIt = true; didIt = true;
expect(payload).toEqual({ x: 'hello ' }); expect(payload).toEqual({ x: 'hello ' });
expect(action).toEqual(dux.actions.foo('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, baz())).toHaveProperty('bar', 1);
expect(foo.reducer(undefined, stopit())).toHaveProperty('bar', 0); 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 // TODO immer that stuff
bar.addMutation(foo.actions.inc, () => (state) => state + 1); bar.addMutation(bar.actions.inc, () => (state) => {
bar.addMutation(foo.actions.reset, () => (state) => 0); return state + 1
});
bar.addMutation(bar.actions.reset, () => (state) => 0);
let seen = 0; let seen = 0;
bar.addReaction((api) => (state, _previous, unsubscribe) => { bar.addReaction((api) => (state, _previous, unsubscribe) => {
@ -62,6 +62,11 @@ test('subdux reactions', () => {
api.dispatch.reset(); api.dispatch.reset();
}); });
const foo = new Updux({
actions: { notInBar: 0 },
subduxes: { bar: bar.asDux },
});
const store = foo.createStore(); const store = foo.createStore();
store.dispatch.inc(); store.dispatch.inc();
@ -69,7 +74,9 @@ test('subdux reactions', () => {
expect(store.getState()).toEqual({ bar: 1 }); expect(store.getState()).toEqual({ bar: 1 });
expect(store.getState.getIt()).toEqual(1); expect(store.getState.getIt()).toEqual(1);
store.dispatch.inc(); store.dispatch.inc();
expect(seen).toEqual(2);
store.dispatch.inc(); store.dispatch.inc();
expect(seen).toEqual(3);
expect(store.getState.getIt()).toEqual(0); // we've been reset expect(store.getState.getIt()).toEqual(0); // we've been reset
@ -78,5 +85,6 @@ test('subdux reactions', () => {
store.dispatch.inc(); store.dispatch.inc();
store.dispatch.inc(); store.dispatch.inc();
expect(seen).toEqual(3);
expect(store.getState.getIt()).toEqual(4); // we've unsubscribed 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 { test, expect } from 'vitest';
import { withPayload } from './actions.js';
import { buildReducer } from './reducer.js'; import { buildReducer } from './reducer.js';
import Updux from './Updux.js'; import Updux from './Updux.js';
@ -22,10 +24,70 @@ test('buildReducer, mutation', () => {
expect(reducer(undefined, { type: 'inc' })).toEqual(2); expect(reducer(undefined, { type: 'inc' })).toEqual(2);
}); });
test.todo('basic reducer', () => { test('basic reducer', () => {
const dux = new Updux({ initialState: { a: 3 } }); 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).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 { Action } from '@reduxjs/toolkit';
import { BaseActionCreator } from '@reduxjs/toolkit/dist/createAction.js';
import * as R from 'remeda'; import * as R from 'remeda';
import { Dux } from './types.js'; import { DuxConfig } from './types.js';
import { Mutation } from './Updux.js'; import { Mutation } from './Updux.js';
import u from '@yanick/updeep-remeda'; import u from '@yanick/updeep-remeda';
import { produce } from 'immer';
export type MutationCase = { export type MutationCase = {
matcher: (action: Action) => boolean; matcher: (action: Action) => boolean;
@ -13,10 +11,10 @@ export type MutationCase = {
}; };
export function buildReducer( export function buildReducer(
initialStateState: any, initialStateState: unknown,
mutations: MutationCase[] = [], mutations: MutationCase[] = [],
defaultMutation?: Omit<MutationCase, 'matcher'>, defaultMutation?: Omit<MutationCase, 'matcher'>,
subduxes: Record<string, Dux> = {}, subduxes: Record<string, DuxConfig> = {},
) { ) {
const subReducers = R.mapValues(subduxes, R.prop('reducer')); const subReducers = R.mapValues(subduxes, R.prop('reducer'));
@ -24,72 +22,42 @@ export function buildReducer(
// TODO defaultMutation // TODO defaultMutation
// //
const reducer = (state = initialStateState, action: Action) => { const reducer = (state = initialStateState, action: Action) => {
const orig = state; if (!action?.type) throw new Error('reducer called with a bad action');
if (!action?.type)
throw new Error('upreducer called with a bad action');
let terminal = false; let active = mutations.filter(({ matcher }) => matcher(action));
let didSomething = false;
mutations if (active.length === 0 && defaultMutation)
.filter(({ matcher }) => matcher(action)) active.push(defaultMutation as any);
.forEach(({ mutation, terminal: t }) => {
if (t) terminal = true; if (
didSomething = true; !active.some(R.prop<any, any>('terminal')) &&
state = produce( Object.values(subReducers).length > 0
) {
active.push({
mutation: (payload, action) => (state) => {
return u(
state, state,
mutation((action as any).payload, action), R.mapValues(
); subReducers,
}); (reducer, slice) => (state) => {
return (reducer as any)(state, action);
if (!didSomething && defaultMutation) { },
if (defaultMutation.terminal) terminal = true;
state = defaultMutation.mutation(
(action as any).payload,
action,
)(state);
}
if (!terminal && Object.keys(subduxes).length > 0) {
// subduxes
state = u.update(
state,
R.mapValues(subReducers, (reducer, slice) =>
(reducer as any)(state[slice], action),
), ),
); );
},
} as any);
} }
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; 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'; import Updux, { createAction } from './index.js';
test('basic selectors', () => { describe('basic selectors', () => {
type State = { x: number }; type State = { x: number };
const foo = new Updux({ const foo = new Updux({
@ -10,7 +10,7 @@ test('basic selectors', () => {
x: 1, x: 1,
}, },
selectors: { selectors: {
getX: ({ x }: State) => x, getX: ({ x }) => x,
}, },
subduxes: { subduxes: {
bar: new Updux({ bar: new Updux({
@ -31,12 +31,16 @@ test('basic selectors', () => {
bar: { y: 3 }, bar: { y: 3 },
}; };
expect(foo.selectors.getY(sample)).toBe(3); test('updux selectors', () => {
expect(foo.selectors.getX(sample)).toBe(4); 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);
});
test.todo('store selectors', () => {
const store = foo.createStore(); const store = foo.createStore();
expect(store.getState.getY()).toBe(2); expect(store.getState.getY()).toBe(2);
expect(store.getState.getYPlus(3)).toBe(5); 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 Updux, { createAction, withPayload } from './index.js';
import u from '@yanick/updeep-remeda'; import u from '@yanick/updeep-remeda';
import { expectTypeOf } from 'expect-type';
export const expectType = <T>(value: T) => value;
test('initialState state', () => { test('initialState state', () => {
const initialState = { const initialState = {
@ -12,10 +11,10 @@ test('initialState state', () => {
initialState, initialState,
}); });
expectType<{ expectTypeOf(dux.initialState).toMatchTypeOf<{
next_id: number; next_id: number;
todos: unknown[]; todos: unknown[];
}>(dux.initialState); }>();
expect(dux.initialState).toEqual(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'; import type { FromSchema } from 'json-schema-to-ts';
import Updux from './Updux.js';
export type Dux< export type { FromSchema } from 'json-schema-to-ts';
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> = ( export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never U extends any ? (k: U) => void : never
) extends (k: infer I) => void ) extends (k: infer I) => void
? I ? I
: never; : 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": { "compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */ /* Visit https://aka.ms/tsconfig to read more about this file */