Merge branch 'tutorial-with-schema'
This commit is contained in:
commit
7e8c2ef171
@ -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:
|
||||
|
@ -1,3 +1,3 @@
|
||||
* [Home](/)
|
||||
* [ Tutorial ](tutorial.md)
|
||||
* [ Recipes ](recipes.md)
|
||||
- [Home](/)
|
||||
- [ Tutorial ](tutorial.md)
|
||||
- [ Recipes ](recipes.md)
|
||||
|
19
docs/nextId.ts
Normal file
19
docs/nextId.ts
Normal 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
20
docs/todo.ts
Normal 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
28
docs/todoList.ts
Normal 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
26
docs/todos.ts
Normal 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
19
docs/tutorial-1.test.js
Normal 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
25
docs/tutorial-1.test.ts
Normal 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: []
|
||||
})
|
||||
});
|
56
docs/tutorial-actions.test.js
Normal file
56
docs/tutorial-actions.test.js
Normal 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,
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
71
docs/tutorial-actions.test.ts
Normal file
71
docs/tutorial-actions.test.ts
Normal 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,
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
@ -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,
|
||||
},
|
||||
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');
|
||||
|
||||
store.dispatch.addTodo('write tutorial');
|
||||
// [effects-1]
|
||||
test('basic', () => {
|
||||
expect(store.getState()).toMatchObject({
|
||||
nextId:2, todos: [ { description: 'Do the thing', id: 1 } ]
|
||||
})
|
||||
|
||||
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' ]);
|
||||
} )
|
||||
|
63
docs/tutorial-effects.test.ts
Normal file
63
docs/tutorial-effects.test.ts
Normal 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 }]
|
||||
})
|
||||
})
|
42
docs/tutorial-final.test.ts
Normal file
42
docs/tutorial-final.test.ts
Normal 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: [],
|
||||
},
|
||||
});
|
||||
|
||||
});
|
78
docs/tutorial-monolith.test.ts
Normal file
78
docs/tutorial-monolith.test.ts
Normal 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 },
|
||||
]
|
||||
});
|
||||
})
|
42
docs/tutorial-selectors.test.ts
Normal file
42
docs/tutorial-selectors.test.ts
Normal 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 });
|
||||
|
||||
});
|
298
docs/tutorial.md
298
docs/tutorial.md
@ -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'.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
@ -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"
|
||||
|
507
src/Updux.ts
507
src/Updux.ts
@ -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 = [];
|
||||
|
||||
constructor(private readonly duxConfig: D) {
|
||||
// just to warn at creation time if config has issues
|
||||
this.actions;
|
||||
}
|
||||
|
||||
memoInitialState = moize(buildInitialState, {
|
||||
maxSize: 1,
|
||||
});
|
||||
|
||||
memoBuildActions = moize(buildActions, {
|
||||
maxSize: 1,
|
||||
});
|
||||
|
||||
memoBuildReducer = moize(buildReducer, { maxSize: 1 });
|
||||
|
||||
memoBuildSelectors = moize(buildSelectors, { maxSize: 1 });
|
||||
|
||||
memoBuildEffects = moize(buildEffects, { maxSize: 1 });
|
||||
|
||||
memoBuildSchema = moize(buildSchema, { maxSize: 1 });
|
||||
|
||||
get schema() {
|
||||
return this.memoBuildSchema(
|
||||
this.duxConfig.schema,
|
||||
this.initialState,
|
||||
this.duxConfig.subduxes,
|
||||
)
|
||||
}
|
||||
|
||||
get initialState(): DuxState<D> {
|
||||
return this.memoInitialState(
|
||||
this.duxConfig.initialState,
|
||||
this.duxConfig.subduxes,
|
||||
);
|
||||
}
|
||||
|
||||
get actions(): DuxActions<D> {
|
||||
return this.memoBuildActions(
|
||||
this.duxConfig.actions,
|
||||
this.duxConfig.subduxes,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
get foo(): DuxActions<D> {
|
||||
return true as any;
|
||||
}
|
||||
|
||||
#name: string;
|
||||
get upreducer() {
|
||||
const reducer = this.reducer;
|
||||
return action => state => reducer(state, action);
|
||||
}
|
||||
|
||||
#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]);
|
||||
}),
|
||||
),
|
||||
// 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,
|
||||
);
|
||||
|
||||
this.#selectors = R.merge(basedSelectors, this.#localSelectors);
|
||||
}
|
||||
|
||||
get actions() {
|
||||
return this.#actions;
|
||||
}
|
||||
|
||||
// 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],
|
||||
}),
|
||||
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];
|
||||
}
|
||||
|
||||
get reactions(): any {
|
||||
return [
|
||||
...this.#localReactions,
|
||||
...Object.entries(this.#subduxes).flatMap(
|
||||
([slice, { reactions }]) =>
|
||||
reactions.map(
|
||||
(r) => (api, unsub) =>
|
||||
r(
|
||||
if (typeof matcher === 'function' && matcher.match) {
|
||||
// matcher, matcher man...
|
||||
matcher = matcher.match;
|
||||
}
|
||||
|
||||
//const immerMutation = (...args) => produce(mutation(...args));
|
||||
|
||||
this.#mutations = [
|
||||
...this.#mutations,
|
||||
{
|
||||
...api,
|
||||
getState: () => api.getState()[slice],
|
||||
terminal,
|
||||
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(
|
||||
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(
|
||||
get reactions() {
|
||||
return [
|
||||
...this.#reactions,
|
||||
...(Object.entries(this.duxConfig.subduxes ?? {}) as any).flatMap(
|
||||
([slice, { reactions }]) =>
|
||||
reactions.map(
|
||||
(r) => (api, unsub) =>
|
||||
r(
|
||||
{
|
||||
...store,
|
||||
subscribe: (subscriber) => this.subscribeTo(store, subscriber), // TODO not sure
|
||||
...api,
|
||||
getState: () => api.getState()[slice],
|
||||
},
|
||||
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);
|
||||
unsub,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
265
src/Updux.ts.2023-08-18
Normal file
265
src/Updux.ts.2023-08-18
Normal 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);
|
||||
}
|
||||
}
|
@ -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 },
|
||||
});
|
||||
});
|
||||
|
124
src/actions.ts
124
src/actions.ts
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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
67
src/createStore.test.ts
Normal 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
33
src/createStore.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
@ -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?
|
||||
|
@ -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 {
|
||||
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: augmentGetState(api.getState, selectors),
|
||||
dispatch: augmentDispatch(api.dispatch, actions),
|
||||
actions,
|
||||
selectors,
|
||||
};
|
||||
};
|
||||
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;
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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'], {})),
|
||||
);
|
||||
}
|
@ -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
16
src/initialState.ts
Normal 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;
|
||||
}
|
@ -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();
|
||||
});
|
||||
|
@ -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
|
||||
});
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
@ -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(
|
||||
if (active.length === 0 && defaultMutation)
|
||||
active.push(defaultMutation as any);
|
||||
|
||||
if (
|
||||
!active.some(R.prop<any, any>('terminal')) &&
|
||||
Object.values(subReducers).length > 0
|
||||
) {
|
||||
active.push({
|
||||
mutation: (payload, action) => (state) => {
|
||||
return u(
|
||||
state,
|
||||
mutation((action as any).payload, 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),
|
||||
R.mapValues(
|
||||
subReducers,
|
||||
(reducer, slice) => (state) => {
|
||||
return (reducer as any)(state, 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;
|
||||
|
||||
/*
|
||||
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
31
src/schema.test.ts
Normal 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
36
src/schema.ts
Normal 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;
|
||||
}
|
@ -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({
|
||||
@ -31,12 +31,16 @@ test('basic selectors', () => {
|
||||
bar: { y: 3 },
|
||||
};
|
||||
|
||||
expect(foo.selectors.getY(sample)).toBe(3);
|
||||
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);
|
||||
});
|
||||
|
||||
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
35
src/selectors.ts
Normal 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]);
|
||||
}
|
@ -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
11
src/types.test.ts
Normal 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>();
|
||||
});
|
73
src/types.ts
73
src/types.ts
@ -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
46
src/types.ts.2023-08-18
Normal 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;
|
@ -1,4 +1,6 @@
|
||||
{
|
||||
"include": ["src/**.ts"],
|
||||
"exclude": ["docs/**.ts"],
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user