Merge branch 'tutorial-with-schema'
This commit is contained in:
commit
7e8c2ef171
@ -9,7 +9,7 @@ tasks:
|
|||||||
build: tsc
|
build: tsc
|
||||||
|
|
||||||
checks:
|
checks:
|
||||||
deps: [lint, test, build]
|
deps: [test, build]
|
||||||
|
|
||||||
integrate:
|
integrate:
|
||||||
deps: [checks]
|
deps: [checks]
|
||||||
@ -20,7 +20,7 @@ tasks:
|
|||||||
- git checkout {{.PARENT_BRANCH}}
|
- git checkout {{.PARENT_BRANCH}}
|
||||||
- git weld -
|
- git weld -
|
||||||
|
|
||||||
test: vitest run src
|
test: vitest run src docs/*.ts
|
||||||
test:dev: vitest src
|
test:dev: vitest src
|
||||||
|
|
||||||
lint:fix:delta:
|
lint:fix:delta:
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
* [Home](/)
|
- [Home](/)
|
||||||
* [ Tutorial ](tutorial.md)
|
- [ Tutorial ](tutorial.md)
|
||||||
* [ Recipes ](recipes.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 { test, expect } from 'vitest';
|
||||||
|
process.env.UPDEEP_MODE = "dangerously_never_freeze";
|
||||||
import u from 'updeep';
|
// [effects-1]
|
||||||
import { action, Updux, dux } from '../src/index.js';
|
import u from '@yanick/updeep-remeda';
|
||||||
|
import * as R from 'remeda';
|
||||||
const addTodoWithId = action('addTodoWithId');
|
import Updux, { createAction, withPayload } from 'updux';
|
||||||
const incNextId = action('incNextId');
|
const addTodoWithId = createAction('addTodoWithId', withPayload());
|
||||||
const addTodo = action('addTodo');
|
const incNextId = createAction('incNextId');
|
||||||
|
const addTodo = createAction('addTodo', withPayload());
|
||||||
const addTodoEffect = ({ getState, dispatch }) => next => action => {
|
|
||||||
const id = getState.nextId();
|
|
||||||
|
|
||||||
dispatch.incNextId();
|
|
||||||
|
|
||||||
next(action);
|
|
||||||
|
|
||||||
dispatch.addTodoWithId({ description: action.payload, id });
|
|
||||||
}
|
|
||||||
|
|
||||||
const todosDux = new Updux({
|
const todosDux = new Updux({
|
||||||
initial: { nextId: 1, todos: [] },
|
initialState: { nextId: 1, todos: [] },
|
||||||
actions: { addTodo, incNextId, addTodoWithId },
|
actions: { addTodo, incNextId, addTodoWithId },
|
||||||
selectors: {
|
selectors: {
|
||||||
nextId: ({nextId}) => nextId,
|
nextId: ({ nextId }) => nextId,
|
||||||
},
|
},
|
||||||
mutations: {
|
|
||||||
addTodoWithId: (todo) => u({ todos: (todos) => [...todos, todo] }),
|
|
||||||
incNextId: () => u({ nextId: id => id+1 }),
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
'addTodo': addTodoEffect
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
todosDux.addMutation(addTodoWithId, (todo) => state => state
|
||||||
|
// u({
|
||||||
|
// todos: R.concat([u(todo, { done: false })])
|
||||||
|
// })
|
||||||
|
);
|
||||||
|
todosDux.addMutation(incNextId, () => state => state); //u({ nextId: id => id + 1 }));
|
||||||
|
// todosDux.addEffect(addTodo, ({ getState, dispatch }) => next => action => {
|
||||||
|
// const id = getState.nextId();
|
||||||
|
// dispatch.incNextId();
|
||||||
|
// next(action);
|
||||||
|
// dispatch.addTodoWithId({ id, description: action.payload });
|
||||||
|
// });
|
||||||
const store = todosDux.createStore();
|
const store = todosDux.createStore();
|
||||||
|
store.dispatch.addTodo('write tutorial');
|
||||||
test( "tutorial example", async () => {
|
// [effects-1]
|
||||||
store.dispatch.addTodo('Do the thing');
|
test('basic', () => {
|
||||||
|
expect(store.getState()).toMatchObject({
|
||||||
expect( store.getState() ).toMatchObject({
|
nextId: 2,
|
||||||
nextId:2, todos: [ { description: 'Do the thing', id: 1 } ]
|
todos: [{ id: 1, description: 'write tutorial', done: false }]
|
||||||
})
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test( "catch-all effect", () => {
|
|
||||||
|
|
||||||
let seen = [];
|
|
||||||
|
|
||||||
const foo = new Updux({
|
|
||||||
actions: {
|
|
||||||
one: null,
|
|
||||||
two: null,
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
'*': (api) => next => action => {
|
|
||||||
seen.push(action.type);
|
|
||||||
next(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} );
|
|
||||||
|
|
||||||
const store = foo.createStore();
|
|
||||||
|
|
||||||
store.dispatch.one();
|
|
||||||
store.dispatch.two();
|
|
||||||
|
|
||||||
expect(seen).toEqual([ 'one', 'two' ]);
|
|
||||||
} )
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
});
|
300
docs/tutorial.md
300
docs/tutorial.md
@ -15,68 +15,42 @@ plain JavaScript.
|
|||||||
## Definition of the state
|
## Definition of the state
|
||||||
|
|
||||||
To begin with, let's define that has nothing but an initial state.
|
To begin with, let's define that has nothing but an initial state.
|
||||||
```js
|
|
||||||
import Updux from 'updux';
|
|
||||||
|
|
||||||
const todosDux = new Updux({
|
[filename](tutorial-1.test.ts ':include :type=code :fragment=tut1')
|
||||||
initial: {
|
|
||||||
nextId: 1,
|
|
||||||
todos: [],
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Congrats! You have written your first Updux object. It
|
Congrats! You have written your first Updux object. It
|
||||||
doesn't do a lot, but you can already create a store out of it, and its
|
doesn't do a lot, but you can already create a store out of it, and its
|
||||||
initial state will be automatically set:
|
initial state will be automatically set:
|
||||||
|
|
||||||
```js
|
[filename](tutorial-1.test.ts ':include :type=code :fragment=tut2')
|
||||||
const store = todosDux.createStore();
|
|
||||||
|
|
||||||
console.log(store.getState()); // prints { nextId: 1, todos: [] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Add actions
|
## Add actions
|
||||||
|
|
||||||
This is all good, but a little static. Let's add actions!
|
This is all good, but very static. Let's add some actions!
|
||||||
```js
|
|
||||||
import { createAction } from 'updux';
|
|
||||||
|
|
||||||
const addTodo = createAction('addTodo');
|
|
||||||
const todoDone = createAction('todoDone');
|
|
||||||
|
|
||||||
const todosDux = new Updux({
|
[filename](tutorial-actions.test.ts ':include :type=code :fragment=actions1')
|
||||||
initial: {
|
|
||||||
nextId: 1,
|
|
||||||
todos: [],
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
addTodo,
|
|
||||||
todoDone,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Accessing actions
|
### Accessing actions
|
||||||
|
|
||||||
Once an action is defined, its creator is accessible via the `actions` accessor.
|
Once an action is defined, its creator is accessible via the `actions` accessor.
|
||||||
This is not yet terribly exciting, but it'll get better once we begin using
|
This is not yet terribly exciting, but it'll get more interesting once we begin using
|
||||||
subduxes.
|
subduxes.
|
||||||
```js
|
|
||||||
console.log( todosDux.actions.addTodo('write tutorial') );
|
[filename](tutorial-actions.test.ts ':include :type=code :fragment=actions2')
|
||||||
// => { type: 'addTodo', payload: 'write tutorial' }
|
|
||||||
```
|
|
||||||
### Adding a mutation
|
### Adding a mutation
|
||||||
|
|
||||||
Mutations are the reducing functions associated to actions. They
|
Mutations are the reducing functions associated to actions. They
|
||||||
are defined via the `mutation` method:
|
are defined via the `addMutation` method.
|
||||||
|
|
||||||
|
[filename](tutorial-actions.test.ts ':include :type=code :fragment=addMutation')
|
||||||
|
|
||||||
|
|
||||||
|
Note that in the mutation we take the liberty of changing the state directly.
|
||||||
|
Typically, that'd be a big no-no, but we're safe here because updux wraps all mutations in an immer `produce`.
|
||||||
|
|
||||||
|
|
||||||
```js
|
|
||||||
dux.mutation(addTodo, (state, description) => {
|
|
||||||
state.todos.unshift({ description, id: state.nextId, done: false });
|
|
||||||
state.nextId++;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
## Effects
|
## Effects
|
||||||
|
|
||||||
In addition to mutations, Updux also provides action-specific middleware, here
|
In addition to mutations, Updux also provides action-specific middleware, here
|
||||||
@ -87,93 +61,16 @@ The `getState` and `dispatch` functions are augmented with the dux selectors,
|
|||||||
and actions, respectively. The selectors and actions are also available
|
and actions, respectively. The selectors and actions are also available
|
||||||
from the api object.
|
from the api object.
|
||||||
|
|
||||||
```js
|
[filename](tutorial-effects.test.ts ':include :type=code :fragment=effects-1')
|
||||||
import u from 'updeep';
|
|
||||||
import { action, Updux } from 'updux';
|
|
||||||
|
|
||||||
// we want to decouple the increment of next_id and the creation of
|
## Selectors
|
||||||
// a new todo. So let's use a new version of the action 'addTodo'.
|
|
||||||
|
|
||||||
const addTodoWithId = action('addTodoWithId');
|
Selectors can be defined to get data derived from the state.
|
||||||
const incNextId = action('incNextId');
|
The `getState` method of a dux store will be augmented
|
||||||
const addTodo = action('addTodo');
|
|
||||||
|
|
||||||
const addTodoEffect = ({ getState, dispatch }) => next => action => {
|
|
||||||
const id = getState.nextId();
|
|
||||||
|
|
||||||
dispatch.incNextId();
|
|
||||||
|
|
||||||
next(action);
|
|
||||||
|
|
||||||
dispatch.addTodoWithId({ description: action.payload, id });
|
|
||||||
}
|
|
||||||
|
|
||||||
const todosDux = new Updux({
|
|
||||||
initial: { nextId: 1, todos: [] },
|
|
||||||
actions: { addTodo, incNextId, addTodoWithId },
|
|
||||||
selectors: {
|
|
||||||
nextId: ({nextId}) => nextId,
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
addTodoWithId: (todo) => u({ todos: (todos) => [...todos, todo] }),
|
|
||||||
incNextId: () => u({ nextId: id => id+1 }),
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
'addTodo': addTodoEffect
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const store = todosDux.createStore();
|
|
||||||
|
|
||||||
store.dispatch.addTodo('Do the thing');
|
|
||||||
```
|
|
||||||
|
|
||||||
### Catch-all effect
|
|
||||||
|
|
||||||
It is possible to have an effect match all actions via the special `*` token.
|
|
||||||
|
|
||||||
```
|
|
||||||
todosUpdux.addEffect('*', () => next => action => {
|
|
||||||
console.log( 'seeing action fly by:', action );
|
|
||||||
next(action);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding selectors
|
|
||||||
|
|
||||||
Selectors can be defined to get data derived from the state.
|
|
||||||
From now you should know the drill: selectors can be defined at construction
|
|
||||||
time or via `setSelector`.
|
|
||||||
|
|
||||||
```
|
|
||||||
const getTodoById = ({todos}) => targetId => todos.find(({id}) => id === targetId);
|
|
||||||
|
|
||||||
const todosUpdux = new Updux({
|
|
||||||
selectors: {
|
|
||||||
getTodoById
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
or
|
|
||||||
|
|
||||||
```
|
|
||||||
todosDux.setSelector('getTodoById', getTodoById);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Accessing selectors
|
|
||||||
|
|
||||||
The `getState` method of a dux store is augmented
|
|
||||||
with its selectors, with the first call for the state already
|
with its selectors, with the first call for the state already
|
||||||
called in for you.
|
curried for you.
|
||||||
|
|
||||||
```js
|
[filename](tutorial-selectors.test.ts ':include :type=code :fragment=sel1')
|
||||||
const store = todosDux.createStore();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
todosUpdux.getState.getTodoById(1)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Subduxes
|
## Subduxes
|
||||||
|
|
||||||
@ -186,47 +83,7 @@ Upduxes can be divided into sub-upduxes that deal with the various parts of
|
|||||||
the global state. This is better understood by working out an example, so
|
the global state. This is better understood by working out an example, so
|
||||||
let's recap on the Todos dux we have so far:
|
let's recap on the Todos dux we have so far:
|
||||||
|
|
||||||
```js
|
[filename](tutorial-monolith.test.ts ':include :type=code :fragment=mono')
|
||||||
import Updux from 'updux';
|
|
||||||
import u from 'updeep';
|
|
||||||
import fp from 'lodash/fp';
|
|
||||||
|
|
||||||
const todosDux = new Updux({
|
|
||||||
initial: {
|
|
||||||
nextId: 1,
|
|
||||||
todos: [],
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
addTodo: null,
|
|
||||||
addTodoWithId: (description, id) => ({description, id, done: false}),
|
|
||||||
todoDone: null,
|
|
||||||
incNextId: null,
|
|
||||||
},
|
|
||||||
selectors: {
|
|
||||||
getTodoById: ({todos}) => id => fp.find({id},todos)
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
addTodoWithId: todo =>
|
|
||||||
u.updateIn( 'todos', todos => [ ...todos, todo] ),
|
|
||||||
incrementNextId: () => u({ nextId: fp.add(1) }),
|
|
||||||
todoDone: (id) => u.updateIn('todos',
|
|
||||||
u.map( u.if( fp.matches({id}), todo => u({done: true}, todo) ) )
|
|
||||||
),
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
addTodo: ({ getState, dispatch }) => next => action => {
|
|
||||||
const { nextId: id } = getState();
|
|
||||||
|
|
||||||
dispatch.incNextId();
|
|
||||||
|
|
||||||
next(action);
|
|
||||||
|
|
||||||
dispatch.addTodoWithId(action.payload, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
This store has two main components: the `nextId`, and the `todos` collection.
|
This store has two main components: the `nextId`, and the `todos` collection.
|
||||||
The `todos` collection is itself composed of the individual `todo`s. Let's
|
The `todos` collection is itself composed of the individual `todo`s. Let's
|
||||||
@ -234,120 +91,19 @@ create upduxes for each of those.
|
|||||||
|
|
||||||
### NextId dux
|
### NextId dux
|
||||||
|
|
||||||
```
|
[filename](nextId.ts ':include :type=code :fragment=dux')
|
||||||
// dux/nextId.js
|
|
||||||
|
|
||||||
import { Updux } from 'updux';
|
|
||||||
import u from 'updeep';
|
|
||||||
|
|
||||||
export default new Updux({
|
|
||||||
initial: 1,
|
|
||||||
actions: {
|
|
||||||
incrementNextId: null,
|
|
||||||
},
|
|
||||||
selectors: {
|
|
||||||
getNextId: state => state
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
incrementNextId: () => state => state + 1,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
### Todo updux
|
### Todo updux
|
||||||
|
|
||||||
```
|
[filename](todo.ts ':include :type=code')
|
||||||
// dux/todos/todo/index.ts
|
|
||||||
|
|
||||||
import { Updux } from 'updux';
|
|
||||||
import u from 'updeep';
|
|
||||||
import fp from 'lodash/fp';
|
|
||||||
|
|
||||||
export default new Updux({
|
|
||||||
initial: {
|
|
||||||
id: 0,
|
|
||||||
description: "",
|
|
||||||
done: false,
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
todoDone: null,
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
todoDone: id => u.if( fp.matches({id}), { done: true }) )
|
|
||||||
},
|
|
||||||
selectors: {
|
|
||||||
desc: ({description}) => description,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
### Todos updux
|
### Todos updux
|
||||||
|
|
||||||
```
|
[filename](todos.ts ':include :type=code')
|
||||||
// dux/todos/index.js
|
|
||||||
|
|
||||||
import { Updux } from 'updux';
|
|
||||||
import u from 'updeep';
|
|
||||||
import fp from 'lodash/fp';
|
|
||||||
|
|
||||||
import todo from './todo/index.js';
|
|
||||||
|
|
||||||
export default new Updux({
|
|
||||||
initial: [],
|
|
||||||
subduxes: {
|
|
||||||
'*': todoDux
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
addTodoWithId: (description, id) => ({description, id} )
|
|
||||||
},
|
|
||||||
findSelectors: {
|
|
||||||
getTodoById: state => id => fp.find({id},state)
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
addTodoWithId: todo =>
|
|
||||||
todos => [ ...todos, todo ]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Note the special '\*' subdux key used here. This
|
|
||||||
allows the updux to map every item present in its
|
|
||||||
state to a `todo` updux. See [this recipe](/recipes?id=mapping-a-mutation-to-all-values-of-a-state) for details.
|
|
||||||
|
|
||||||
### Main store
|
### Main store
|
||||||
|
|
||||||
```
|
[filename](todoList.ts ':include :type=code')
|
||||||
// dux/index.js
|
|
||||||
|
|
||||||
import Updux from 'updux';
|
|
||||||
|
|
||||||
import todos from './todos';
|
|
||||||
import nextId from './next_id';
|
|
||||||
|
|
||||||
export new Updux({
|
|
||||||
subduxes: {
|
|
||||||
nextId,
|
|
||||||
todos,
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
addTodo: null
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
addTodo: ({ getState, dispatch }) => next => action => {
|
|
||||||
const id = getState.getNextId();
|
|
||||||
|
|
||||||
dispatch.incrementNextId()
|
|
||||||
|
|
||||||
next(action);
|
|
||||||
|
|
||||||
dispatch.addTodoWithId( action.payload, id );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Tadah! We had to define the `addTodo` effect at the top level as it needs to
|
Tadah! We had to define the `addTodo` effect at the top level as it needs to
|
||||||
access the `getNextId` selector from `nextId` and the `addTodoWithId`
|
access the `getNextId` selector from `nextId` and the `addTodoWithId`
|
||||||
@ -355,7 +111,7 @@ action from the `todos`.
|
|||||||
|
|
||||||
Note that the `getNextId` selector still gets the
|
Note that the `getNextId` selector still gets the
|
||||||
right value; when aggregating subduxes selectors Updux auto-wraps them to
|
right value; when aggregating subduxes selectors Updux auto-wraps them to
|
||||||
access the right slice of the top object. ```
|
access the right slice of the top object.
|
||||||
|
|
||||||
## Reactions
|
## Reactions
|
||||||
|
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
{
|
{
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@yanick/updeep-remeda": "^2.1.0",
|
"@yanick/updeep-remeda": "^2.2.0",
|
||||||
|
"expect-type": "^0.16.0",
|
||||||
"immer": "^9.0.15",
|
"immer": "^9.0.15",
|
||||||
"json-schema-shorthand": "^2.0.0",
|
"json-schema-shorthand": "^2.0.0",
|
||||||
|
"json-schema-to-ts": "^2.9.2",
|
||||||
|
"memoize-one": "^6.0.0",
|
||||||
|
"moize": "^6.1.6",
|
||||||
"redux": "^4.2.0",
|
"redux": "^4.2.0",
|
||||||
"remeda": "^1.0.1",
|
"remeda": "^1.0.1",
|
||||||
"updeep": "^1.2.1"
|
"updeep": "^1.2.1"
|
||||||
@ -45,6 +49,7 @@
|
|||||||
"jsdoc-to-markdown": "^7.1.1",
|
"jsdoc-to-markdown": "^7.1.1",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.7.1",
|
||||||
"redux-toolkit": "^1.1.2",
|
"redux-toolkit": "^1.1.2",
|
||||||
|
"tsdoc-markdown": "^0.0.4",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
"vite": "^4.2.1",
|
"vite": "^4.2.1",
|
||||||
"vitest": "0.23.1"
|
"vitest": "0.23.1"
|
||||||
|
495
src/Updux.ts
495
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 * as R from 'remeda';
|
||||||
|
import { expandAction, buildActions, DuxActions } from './actions.js';
|
||||||
import {
|
import {
|
||||||
createStore as reduxCreateStore,
|
|
||||||
applyMiddleware,
|
|
||||||
DeepPartial,
|
|
||||||
Action,
|
Action,
|
||||||
MiddlewareAPI,
|
|
||||||
AnyAction,
|
|
||||||
Middleware,
|
|
||||||
Dispatch,
|
|
||||||
} from 'redux';
|
|
||||||
import {
|
|
||||||
configureStore,
|
|
||||||
Reducer,
|
|
||||||
ActionCreator,
|
ActionCreator,
|
||||||
ActionCreatorWithoutPayload,
|
AnyAction,
|
||||||
ActionCreatorWithPreparedPayload,
|
configureStore,
|
||||||
|
EnhancedStore,
|
||||||
} from '@reduxjs/toolkit';
|
} from '@reduxjs/toolkit';
|
||||||
import { AggregateActions, AggregateSelectors, Dux } from './types.js';
|
import { produce } from 'immer';
|
||||||
import { buildActions } from './buildActions.js';
|
import { buildReducer } from './reducer.js';
|
||||||
import { buildInitial, AggregateState } from './initial.js';
|
import { buildInitialState } from './initialState.js';
|
||||||
import { buildReducer, MutationCase } from './reducer.js';
|
import { buildSelectors, DuxSelectors } from './selectors.js';
|
||||||
|
import { AugmentedMiddlewareAPI, augmentGetState } from './createStore.js';
|
||||||
import {
|
import {
|
||||||
augmentGetState,
|
|
||||||
augmentMiddlewareApi,
|
augmentMiddlewareApi,
|
||||||
|
buildEffects,
|
||||||
buildEffectsMiddleware,
|
buildEffectsMiddleware,
|
||||||
EffectMiddleware,
|
EffectMiddleware,
|
||||||
} from './effects.js';
|
} from './effects.js';
|
||||||
import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore.js';
|
import buildSchema from './schema.js';
|
||||||
import { produce } from 'immer';
|
|
||||||
|
|
||||||
type MyActionCreator = { type: string } & ((...args: any) => any);
|
export type Mutation<A = AnyAction, S = any> = (
|
||||||
|
|
||||||
type XSel<R> = R extends Function ? R : () => R;
|
|
||||||
type CurriedSelector<S> = S extends (...args: any) => infer R ? XSel<R> : never;
|
|
||||||
|
|
||||||
type CurriedSelectors<S> = {
|
|
||||||
[key in keyof S]: CurriedSelector<S[key]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResolveAction<
|
|
||||||
ActionType extends string,
|
|
||||||
ActionArg extends any,
|
|
||||||
> = ActionArg extends MyActionCreator
|
|
||||||
? ActionArg
|
|
||||||
: ActionArg extends (...args: any) => any
|
|
||||||
? ActionCreatorWithPreparedPayload<
|
|
||||||
Parameters<ActionArg>,
|
|
||||||
ReturnType<ActionArg>,
|
|
||||||
ActionType
|
|
||||||
>
|
|
||||||
: ActionCreatorWithoutPayload<ActionType>;
|
|
||||||
|
|
||||||
type ResolveActions<
|
|
||||||
A extends {
|
|
||||||
[key: string]: any;
|
|
||||||
},
|
|
||||||
> = {
|
|
||||||
[ActionType in keyof A]: ActionType extends string
|
|
||||||
? ResolveAction<ActionType, A[ActionType]>
|
|
||||||
: never;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Reaction<S = any, M extends MiddlewareAPI = MiddlewareAPI> = (
|
|
||||||
api: M,
|
|
||||||
) => (state: S, previousState: S, unsubscribe: () => void) => any;
|
|
||||||
|
|
||||||
type AugmentedMiddlewareAPI<S, A, SELECTORS> = MiddlewareAPI<
|
|
||||||
Dispatch<AnyAction>,
|
|
||||||
S
|
|
||||||
> & {
|
|
||||||
dispatch: A;
|
|
||||||
getState: CurriedSelectors<SELECTORS>;
|
|
||||||
actions: A;
|
|
||||||
selectors: SELECTORS;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Mutation<A extends Action<any> = Action<any>, S = any> = (
|
|
||||||
payload: A extends {
|
payload: A extends {
|
||||||
payload: infer P;
|
payload: infer P;
|
||||||
}
|
}
|
||||||
@ -84,124 +32,151 @@ export type Mutation<A extends Action<any> = Action<any>, S = any> = (
|
|||||||
action: A,
|
action: A,
|
||||||
) => (state: S) => S | void;
|
) => (state: S) => S | void;
|
||||||
|
|
||||||
type SelectorForState<S> = (state: S) => unknown;
|
export default class Updux<D extends DuxConfig> {
|
||||||
type SelectorsForState<S> = {
|
#mutations = [];
|
||||||
[key: string]: SelectorForState<S>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class Updux<
|
constructor(private readonly duxConfig: D) {
|
||||||
T_LocalState = Record<any, any>,
|
// just to warn at creation time if config has issues
|
||||||
T_LocalActions extends {
|
this.actions;
|
||||||
[actionType: string]: any;
|
}
|
||||||
} = {},
|
|
||||||
T_Subduxes extends Record<string, Dux> = {},
|
|
||||||
T_LocalSelectors extends SelectorsForState<
|
|
||||||
AggregateState<T_LocalState, T_Subduxes>
|
|
||||||
> = {},
|
|
||||||
> {
|
|
||||||
#localInitial: T_LocalState;
|
|
||||||
#localActions: T_LocalActions;
|
|
||||||
#localMutations: MutationCase[] = [];
|
|
||||||
#defaultMutation: Omit<MutationCase, 'matcher'>;
|
|
||||||
#subduxes: T_Subduxes;
|
|
||||||
|
|
||||||
#name: string;
|
memoInitialState = moize(buildInitialState, {
|
||||||
|
maxSize: 1,
|
||||||
|
});
|
||||||
|
|
||||||
#actions: AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>;
|
memoBuildActions = moize(buildActions, {
|
||||||
|
maxSize: 1,
|
||||||
|
});
|
||||||
|
|
||||||
#initialState: AggregateState<T_LocalState, T_Subduxes>;
|
memoBuildReducer = moize(buildReducer, { maxSize: 1 });
|
||||||
|
|
||||||
#localSelectors: Record<
|
memoBuildSelectors = moize(buildSelectors, { maxSize: 1 });
|
||||||
string,
|
|
||||||
(state: AggregateState<T_LocalState, T_Subduxes>) => any
|
|
||||||
>;
|
|
||||||
#selectors: any;
|
|
||||||
|
|
||||||
#localEffects: Middleware[] = [];
|
memoBuildEffects = moize(buildEffects, { maxSize: 1 });
|
||||||
|
|
||||||
constructor(
|
memoBuildSchema = moize(buildSchema, { maxSize: 1 });
|
||||||
config: Partial<{
|
|
||||||
initialState: T_LocalState;
|
|
||||||
actions: T_LocalActions;
|
|
||||||
subduxes: T_Subduxes;
|
|
||||||
selectors: T_LocalSelectors;
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
// TODO check that we can't alter the initialState after the fact
|
|
||||||
this.#localInitial = config.initialState ?? ({} as T_LocalState);
|
|
||||||
this.#localActions = config.actions ?? ({} as T_LocalActions);
|
|
||||||
this.#subduxes = config.subduxes ?? ({} as T_Subduxes);
|
|
||||||
|
|
||||||
this.#actions = buildActions(this.#localActions, this.#subduxes);
|
get schema() {
|
||||||
|
return this.memoBuildSchema(
|
||||||
|
this.duxConfig.schema,
|
||||||
|
this.initialState,
|
||||||
|
this.duxConfig.subduxes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
this.#initialState = buildInitial(this.#localInitial, this.#subduxes);
|
get initialState(): DuxState<D> {
|
||||||
this.#localSelectors = config.selectors;
|
return this.memoInitialState(
|
||||||
|
this.duxConfig.initialState,
|
||||||
const basedSelectors = R.mergeAll(
|
this.duxConfig.subduxes,
|
||||||
Object.entries(this.#subduxes)
|
|
||||||
.filter(([slice, { selectors }]) => selectors)
|
|
||||||
.map(([slice, { selectors }]) =>
|
|
||||||
R.mapValues(selectors, (s) => (state = {}) => {
|
|
||||||
return s(state?.[slice]);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.#selectors = R.merge(basedSelectors, this.#localSelectors);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get actions() {
|
get actions(): DuxActions<D> {
|
||||||
return this.#actions;
|
return this.memoBuildActions(
|
||||||
|
this.duxConfig.actions,
|
||||||
|
this.duxConfig.subduxes,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO memoize?
|
toDux() {
|
||||||
get initialState() {
|
return {
|
||||||
return this.#initialState;
|
initialState: this.initialState,
|
||||||
|
actions: this.actions,
|
||||||
|
reducer: this.reducer,
|
||||||
|
effects: this.effects,
|
||||||
|
reactions: this.reactions,
|
||||||
|
selectors: this.selectors,
|
||||||
|
upreducer: this.upreducer,
|
||||||
|
schema: this.schema,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
get asDux() {
|
||||||
|
return this.toDux();
|
||||||
}
|
}
|
||||||
|
|
||||||
get effects() {
|
get foo(): DuxActions<D> {
|
||||||
return [
|
return true as any;
|
||||||
...this.#localEffects,
|
}
|
||||||
...Object.entries(this.#subduxes).flatMap(
|
|
||||||
([slice, { effects }]) => {
|
get upreducer() {
|
||||||
if (!effects) return [];
|
const reducer = this.reducer;
|
||||||
return effects.map(
|
return action => state => reducer(state, action);
|
||||||
(effect) => (api) =>
|
}
|
||||||
effect({
|
|
||||||
...api,
|
// TODO be smarter with the guard?
|
||||||
getState: () => api.getState()[slice],
|
addMutation<A extends keyof DuxActions<D>>(
|
||||||
}),
|
matcher: A,
|
||||||
);
|
mutation: Mutation<DuxActions<D>[A] extends (...args: any) => infer P ? P : never, DuxState<D>>,
|
||||||
},
|
terminal?: boolean,
|
||||||
),
|
);
|
||||||
|
addMutation<A extends Action<any>>(
|
||||||
|
matcher: (action: A) => boolean,
|
||||||
|
mutation: Mutation<A, DuxState<D>>,
|
||||||
|
terminal?: boolean,
|
||||||
|
);
|
||||||
|
addMutation<A extends ActionCreator<any>>(
|
||||||
|
actionCreator: A,
|
||||||
|
mutation: Mutation<ReturnType<A>, DuxState<D>>,
|
||||||
|
terminal?: boolean,
|
||||||
|
);
|
||||||
|
addMutation(matcher, mutation, terminal = false) {
|
||||||
|
|
||||||
|
if (typeof matcher === 'string') {
|
||||||
|
if (!this.actions[matcher]) {
|
||||||
|
throw new Error(`action ${matcher} is unknown`);
|
||||||
|
}
|
||||||
|
matcher = this.actions[matcher];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof matcher === 'function' && matcher.match) {
|
||||||
|
// matcher, matcher man...
|
||||||
|
matcher = matcher.match;
|
||||||
|
}
|
||||||
|
|
||||||
|
//const immerMutation = (...args) => produce(mutation(...args));
|
||||||
|
|
||||||
|
this.#mutations = [
|
||||||
|
...this.#mutations,
|
||||||
|
{
|
||||||
|
terminal,
|
||||||
|
matcher,
|
||||||
|
mutation,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
get reactions(): any {
|
#defaultMutation;
|
||||||
return [
|
|
||||||
...this.#localReactions,
|
addDefaultMutation(
|
||||||
...Object.entries(this.#subduxes).flatMap(
|
mutation: Mutation<any, DuxState<D>>,
|
||||||
([slice, { reactions }]) =>
|
terminal?: boolean,
|
||||||
reactions.map(
|
);
|
||||||
(r) => (api, unsub) =>
|
addDefaultMutation(mutation, terminal = false) {
|
||||||
r(
|
this.#defaultMutation = { terminal, mutation };
|
||||||
{
|
}
|
||||||
...api,
|
|
||||||
getState: () => api.getState()[slice],
|
get reducer() {
|
||||||
},
|
return this.memoBuildReducer(
|
||||||
unsub,
|
this.initialState,
|
||||||
),
|
this.#mutations,
|
||||||
),
|
this.#defaultMutation,
|
||||||
),
|
this.duxConfig.subduxes,
|
||||||
];
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectors(): DuxSelectors<D> {
|
||||||
|
return this.memoBuildSelectors(
|
||||||
|
this.duxConfig.selectors,
|
||||||
|
this.duxConfig.subduxes,
|
||||||
|
) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
createStore(
|
createStore(
|
||||||
options: Partial<{
|
options: Partial<{
|
||||||
preloadedState: T_LocalState;
|
preloadedState: DuxState<D>;
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
): EnhancedStore<DuxState<D>> & AugmentedMiddlewareAPI<D> {
|
||||||
const preloadedState: any = options.preloadedState;
|
const preloadedState = options.preloadedState;
|
||||||
|
|
||||||
const effects = buildEffectsMiddleware(
|
const effects = buildEffectsMiddleware(
|
||||||
this.effects,
|
this.effects,
|
||||||
@ -210,10 +185,7 @@ export default class Updux<
|
|||||||
);
|
);
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: this.reducer as Reducer<
|
reducer: this.reducer,
|
||||||
AggregateState<T_LocalState, T_Subduxes>,
|
|
||||||
AnyAction
|
|
||||||
>,
|
|
||||||
preloadedState,
|
preloadedState,
|
||||||
middleware: [effects],
|
middleware: [effects],
|
||||||
});
|
});
|
||||||
@ -239,104 +211,50 @@ export default class Updux<
|
|||||||
(store as any).actions = this.actions;
|
(store as any).actions = this.actions;
|
||||||
(store as any).selectors = this.selectors;
|
(store as any).selectors = this.selectors;
|
||||||
|
|
||||||
return store as ToolkitStore<AggregateState<T_LocalState, T_Subduxes>> &
|
return store as any;
|
||||||
AugmentedMiddlewareAPI<
|
|
||||||
AggregateState<T_LocalState, T_Subduxes>,
|
// return store as ToolkitStore<AggregateState<T_LocalState, T_Subduxes>> &
|
||||||
AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>,
|
// AugmentedMiddlewareAPI<
|
||||||
AggregateSelectors<
|
// AggregateState<T_LocalState, T_Subduxes>,
|
||||||
T_LocalSelectors,
|
// AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>,
|
||||||
T_Subduxes,
|
// AggregateSelectors<
|
||||||
AggregateState<T_LocalState, T_Subduxes>
|
// T_LocalSelectors,
|
||||||
>
|
// T_Subduxes,
|
||||||
>;
|
// AggregateState<T_LocalState, T_Subduxes>
|
||||||
|
// >
|
||||||
|
// >;
|
||||||
}
|
}
|
||||||
|
|
||||||
get selectors(): AggregateSelectors<
|
#effects = [];
|
||||||
T_LocalSelectors,
|
|
||||||
T_Subduxes,
|
|
||||||
AggregateState<T_LocalState, T_Subduxes>
|
|
||||||
> {
|
|
||||||
return this.#selectors as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO memoize this sucker
|
|
||||||
get reducer() {
|
|
||||||
return buildReducer(
|
|
||||||
this.initialState,
|
|
||||||
this.#localMutations,
|
|
||||||
this.#defaultMutation,
|
|
||||||
this.#subduxes,
|
|
||||||
) as any as (
|
|
||||||
state: undefined | typeof this.initialState,
|
|
||||||
action: Action,
|
|
||||||
) => typeof this.initialState;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO be smarter with the guard?
|
|
||||||
addMutation<A extends Action<any>>(
|
|
||||||
matcher: (action: A) => boolean,
|
|
||||||
mutation: Mutation<A, AggregateState<T_LocalState, T_Subduxes>>,
|
|
||||||
terminal?: boolean,
|
|
||||||
);
|
|
||||||
addMutation<A extends ActionCreator<any>>(
|
|
||||||
actionCreator: A,
|
|
||||||
mutation: Mutation<
|
|
||||||
ReturnType<A>,
|
|
||||||
AggregateState<T_LocalState, T_Subduxes>
|
|
||||||
>,
|
|
||||||
terminal?: boolean,
|
|
||||||
);
|
|
||||||
addMutation(matcher, mutation, terminal = false) {
|
|
||||||
if (typeof matcher === 'function' && matcher.match) {
|
|
||||||
// matcher, matcher man...
|
|
||||||
matcher = matcher.match;
|
|
||||||
}
|
|
||||||
|
|
||||||
const immerMutation = (...args) => produce(mutation(...args));
|
|
||||||
|
|
||||||
this.#localMutations.push({
|
|
||||||
terminal,
|
|
||||||
matcher,
|
|
||||||
mutation: immerMutation,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
addDefaultMutation(
|
|
||||||
mutation: Mutation<
|
|
||||||
Action<any>,
|
|
||||||
AggregateState<T_LocalState, T_Subduxes>
|
|
||||||
>,
|
|
||||||
terminal = false,
|
|
||||||
) {
|
|
||||||
this.#defaultMutation = { mutation, terminal };
|
|
||||||
}
|
|
||||||
|
|
||||||
addEffect(
|
addEffect(
|
||||||
actionCreator: AggregateActions<
|
actionType: keyof DuxActions<D>,
|
||||||
ResolveActions<T_LocalActions>,
|
effect: EffectMiddleware<D>,
|
||||||
T_Subduxes
|
): EffectMiddleware<D>;
|
||||||
>,
|
|
||||||
effect: EffectMiddleware,
|
|
||||||
): EffectMiddleware;
|
|
||||||
addEffect(
|
addEffect(
|
||||||
guardFunc: (
|
actionCreator: { match: (action: any) => boolean },
|
||||||
action: AggregateActions<
|
effect: EffectMiddleware<D>,
|
||||||
ResolveActions<T_LocalActions>,
|
): EffectMiddleware<D>;
|
||||||
T_Subduxes
|
addEffect(
|
||||||
>[keyof AggregateActions<
|
guardFunc: (action: AnyAction) => boolean,
|
||||||
ResolveActions<T_LocalActions>,
|
effect: EffectMiddleware<D>,
|
||||||
T_Subduxes
|
): EffectMiddleware<D>;
|
||||||
>],
|
addEffect(effect: EffectMiddleware<D>): EffectMiddleware<D>;
|
||||||
) => boolean,
|
|
||||||
effect: EffectMiddleware,
|
|
||||||
): EffectMiddleware;
|
|
||||||
addEffect(effect: EffectMiddleware): EffectMiddleware;
|
|
||||||
addEffect(...args) {
|
addEffect(...args) {
|
||||||
let effect;
|
let effect;
|
||||||
if (args.length === 1) {
|
if (args.length === 1) {
|
||||||
effect = args[0];
|
effect = args[0];
|
||||||
} else {
|
} else {
|
||||||
const [actionCreator, originalEffect] = args;
|
let [actionCreator, originalEffect] = args;
|
||||||
|
|
||||||
|
if (typeof actionCreator === 'string') {
|
||||||
|
if (this.actions[actionCreator]) {
|
||||||
|
actionCreator = this.actions[actionCreator];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error(`action '${actionCreator}' is unknown`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const test = actionCreator.hasOwnProperty('match')
|
const test = actionCreator.hasOwnProperty('match')
|
||||||
? actionCreator.match
|
? actionCreator.match
|
||||||
@ -351,33 +269,18 @@ export default class Updux<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#localEffects.push(effect);
|
this.#effects = [...this.#effects, effect];
|
||||||
|
|
||||||
return effect;
|
return effect;
|
||||||
}
|
}
|
||||||
|
|
||||||
get effectsMiddleware() {
|
get effects() {
|
||||||
return buildEffectsMiddleware(
|
return this.memoBuildEffects(this.#effects, this.duxConfig.subduxes);
|
||||||
this.effects,
|
|
||||||
this.actions,
|
|
||||||
this.selectors,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#localReactions: any[] = [];
|
#reactions = [];
|
||||||
addReaction(
|
addReaction(
|
||||||
reaction: Reaction<
|
reaction// :DuxReaction<D>
|
||||||
AggregateState<T_LocalState, T_Subduxes>,
|
|
||||||
AugmentedMiddlewareAPI<
|
|
||||||
AggregateState<T_LocalState, T_Subduxes>,
|
|
||||||
AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>,
|
|
||||||
AggregateSelectors<
|
|
||||||
T_LocalSelectors,
|
|
||||||
T_Subduxes,
|
|
||||||
AggregateState<T_LocalState, T_Subduxes>
|
|
||||||
>
|
|
||||||
>
|
|
||||||
>,
|
|
||||||
) {
|
) {
|
||||||
let previous: any;
|
let previous: any;
|
||||||
|
|
||||||
@ -392,33 +295,27 @@ export default class Updux<
|
|||||||
r(state, p, unsub);
|
r(state, p, unsub);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
this.#localReactions.push(memoized);
|
this.#reactions = [
|
||||||
|
...this.#reactions, memoized
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// internal method REMOVE
|
get reactions() {
|
||||||
subscribeTo(store, subscription) {
|
return [
|
||||||
const localStore = augmentMiddlewareApi(
|
...this.#reactions,
|
||||||
{
|
...(Object.entries(this.duxConfig.subduxes ?? {}) as any).flatMap(
|
||||||
...store,
|
([slice, { reactions }]) =>
|
||||||
subscribe: (subscriber) => this.subscribeTo(store, subscriber), // TODO not sure
|
reactions.map(
|
||||||
},
|
(r) => (api, unsub) =>
|
||||||
this.actions,
|
r(
|
||||||
this.selectors,
|
{
|
||||||
);
|
...api,
|
||||||
|
getState: () => api.getState()[slice],
|
||||||
const subscriber = subscription(localStore);
|
},
|
||||||
|
unsub,
|
||||||
let previous;
|
),
|
||||||
let unsub;
|
),
|
||||||
|
),
|
||||||
const memoSub = () => {
|
];
|
||||||
const state = store.getState();
|
|
||||||
if (state === previous) return;
|
|
||||||
let p = previous;
|
|
||||||
previous = state;
|
|
||||||
subscriber(state, p, unsub);
|
|
||||||
};
|
|
||||||
|
|
||||||
return store.subscribe(memoSub);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
265
src/Updux.ts.2023-08-18
Normal file
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', () => {
|
test('basic action', () => {
|
||||||
const foo = createAction(
|
const foo = createAction(
|
||||||
@ -12,6 +19,132 @@ test('basic action', () => {
|
|||||||
thing: 'bar',
|
thing: 'bar',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expectTypeOf(foo).parameters.toMatchTypeOf<[string]>();
|
||||||
|
|
||||||
|
expectTypeOf(foo).returns.toMatchTypeOf<{
|
||||||
|
type: 'foo';
|
||||||
|
payload: { thing: string };
|
||||||
|
}>();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Updux config accepts actions', () => {
|
||||||
|
const foo = new Updux({
|
||||||
|
actions: {
|
||||||
|
one: createAction(
|
||||||
|
'one',
|
||||||
|
withPayload((x) => ({ x })),
|
||||||
|
),
|
||||||
|
two: createAction(
|
||||||
|
'two',
|
||||||
|
withPayload((x) => x),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Object.keys(foo.actions)).toHaveLength(2);
|
||||||
|
|
||||||
|
expect(foo.actions.one).toBeTypeOf('function');
|
||||||
|
expect(foo.actions.one('potato')).toEqual({
|
||||||
|
type: 'one',
|
||||||
|
payload: {
|
||||||
|
x: 'potato',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expectTypeOf(foo.actions.one).toMatchTypeOf<
|
||||||
|
ActionCreatorWithPreparedPayload<any, any, any, any>
|
||||||
|
>();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expandAction', () => {
|
||||||
|
test('as-is', () => {
|
||||||
|
const result = expandAction(
|
||||||
|
createAction('foo', withPayload<boolean>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectTypeOf(result).toMatchTypeOf<
|
||||||
|
(input: boolean) => { type: 'foo'; payload: boolean }
|
||||||
|
>();
|
||||||
|
|
||||||
|
expect(result(true)).toMatchObject({
|
||||||
|
type: 'foo',
|
||||||
|
payload: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('0', () => {
|
||||||
|
const result = expandAction(0, 'foo');
|
||||||
|
|
||||||
|
expectTypeOf(result).toMatchTypeOf<() => { type: 'foo' }>();
|
||||||
|
|
||||||
|
expect(result()).toMatchObject({
|
||||||
|
type: 'foo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('function', () => {
|
||||||
|
const result = expandAction((size: number) => ({ size }), 'foo');
|
||||||
|
|
||||||
|
expectTypeOf(result).toMatchTypeOf<
|
||||||
|
() => { type: 'foo'; payload: { size: number } }
|
||||||
|
>();
|
||||||
|
|
||||||
|
expect(result(12)).toMatchObject({
|
||||||
|
type: 'foo',
|
||||||
|
payload: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('action types', () => {
|
||||||
|
const action1 = createAction('a1', withPayload<number>());
|
||||||
|
expectTypeOf(
|
||||||
|
true as any as ExpandedAction<typeof action1, 'a1'>,
|
||||||
|
).toMatchTypeOf<
|
||||||
|
ActionCreatorWithPreparedPayload<
|
||||||
|
[input: number],
|
||||||
|
number,
|
||||||
|
'a1',
|
||||||
|
never,
|
||||||
|
never
|
||||||
|
>
|
||||||
|
>();
|
||||||
|
const action2 = (input: boolean) => input;
|
||||||
|
let typed2: ExpandedAction<typeof action2, 'a2'>;
|
||||||
|
expectTypeOf(typed2).toMatchTypeOf<
|
||||||
|
ActionCreatorWithPreparedPayload<
|
||||||
|
[input: boolean],
|
||||||
|
boolean,
|
||||||
|
'a2',
|
||||||
|
never,
|
||||||
|
never
|
||||||
|
>
|
||||||
|
>();
|
||||||
|
let typed3: ExpandedAction<boolean, 'a3'>;
|
||||||
|
expectTypeOf(typed3).toMatchTypeOf<ActionCreatorWithoutPayload<'a3'>>();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('action definition shortcut', () => {
|
||||||
|
const foo = new Updux({
|
||||||
|
actions: {
|
||||||
|
foo: 0,
|
||||||
|
bar: (x: number) => ({ x }),
|
||||||
|
baz: createAction('baz', withPayload<boolean>()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(foo.actions.foo()).toEqual({ type: 'foo', payload: undefined });
|
||||||
|
expect(foo.actions.baz(false)).toEqual({
|
||||||
|
type: 'baz',
|
||||||
|
payload: false,
|
||||||
|
});
|
||||||
|
expect(foo.actions.bar(2)).toEqual({
|
||||||
|
type: 'bar',
|
||||||
|
payload: { x: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expectTypeOf(foo.actions.foo).toMatchTypeOf<ActionCreatorWithoutPayload<'foo'>>();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subduxes actions', () => {
|
test('subduxes actions', () => {
|
||||||
@ -38,31 +171,10 @@ test('subduxes actions', () => {
|
|||||||
|
|
||||||
expect(foo.actions.bar(2)).toHaveProperty('type', 'bar');
|
expect(foo.actions.bar(2)).toHaveProperty('type', 'bar');
|
||||||
expect(foo.actions.baz()).toHaveProperty('type', 'baz');
|
expect(foo.actions.baz()).toHaveProperty('type', 'baz');
|
||||||
});
|
|
||||||
|
|
||||||
test('Updux config accepts actions', () => {
|
expectTypeOf(foo.actions.baz).toMatchTypeOf<
|
||||||
const foo = new Updux({
|
ActionCreatorWithoutPayload<'baz'>
|
||||||
actions: {
|
>();
|
||||||
one: createAction(
|
|
||||||
'one',
|
|
||||||
withPayload((x) => ({ x })),
|
|
||||||
),
|
|
||||||
two: createAction(
|
|
||||||
'two',
|
|
||||||
withPayload((x) => x),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(Object.keys(foo.actions)).toHaveLength(2);
|
|
||||||
|
|
||||||
expect(foo.actions.one).toBeTypeOf('function');
|
|
||||||
expect(foo.actions.one('potato')).toEqual({
|
|
||||||
type: 'one',
|
|
||||||
payload: {
|
|
||||||
x: 'potato',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('throw if double action', () => {
|
test('throw if double action', () => {
|
||||||
@ -100,23 +212,3 @@ test('throw if double action', () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow(/action 'foo' defined both in subduxes 'gamma' and 'beta'/);
|
).toThrow(/action 'foo' defined both in subduxes 'gamma' and 'beta'/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('action definition shortcut', () => {
|
|
||||||
const foo = new Updux({
|
|
||||||
actions: {
|
|
||||||
foo: 0,
|
|
||||||
bar: (x: number) => ({ x }),
|
|
||||||
baz: createAction('baz', withPayload<boolean>()),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(foo.actions.foo()).toEqual({ type: 'foo', payload: undefined });
|
|
||||||
expect(foo.actions.baz(false)).toEqual({
|
|
||||||
type: 'baz',
|
|
||||||
payload: false,
|
|
||||||
});
|
|
||||||
expect(foo.actions.bar(2)).toEqual({
|
|
||||||
type: 'bar',
|
|
||||||
payload: { x: 2 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
126
src/actions.ts
126
src/actions.ts
@ -1,29 +1,107 @@
|
|||||||
import { createAction } from '@reduxjs/toolkit';
|
import {
|
||||||
|
ActionCreator,
|
||||||
|
ActionCreatorWithoutPayload,
|
||||||
|
createAction,
|
||||||
|
} from '@reduxjs/toolkit';
|
||||||
export { createAction } from '@reduxjs/toolkit';
|
export { createAction } from '@reduxjs/toolkit';
|
||||||
|
import { ActionCreatorWithPreparedPayload } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
interface WithPayload {
|
import type {
|
||||||
<P>(): (input: P) => { payload: P };
|
FromSchema,
|
||||||
<P, A extends any[]>(prepare: (...args: A) => P): (...input: A) => {
|
SubduxesState,
|
||||||
payload: P;
|
DuxSchema,
|
||||||
};
|
DuxConfig,
|
||||||
|
UnionToIntersection,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
import * as R from 'remeda';
|
||||||
|
|
||||||
|
export type DuxActions<D> = (D extends { actions: infer A }
|
||||||
|
? {
|
||||||
|
[key in keyof A]: key extends string
|
||||||
|
? ExpandedAction<A[key], key>
|
||||||
|
: never;
|
||||||
|
}
|
||||||
|
: {}) &
|
||||||
|
UnionToIntersection<
|
||||||
|
D extends { subduxes: infer S } ? DuxActions<S[keyof S]> : {}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ExpandedAction<P, T extends string> = P extends (...a: any[]) => {
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
? P
|
||||||
|
: P extends (...a: any[]) => any
|
||||||
|
? ActionCreatorWithPreparedPayload<
|
||||||
|
Parameters<P>,
|
||||||
|
ReturnType<P>,
|
||||||
|
T,
|
||||||
|
never,
|
||||||
|
never
|
||||||
|
>
|
||||||
|
: ActionCreatorWithoutPayload<T>;
|
||||||
|
|
||||||
|
export type DuxState<D> = (D extends { schema: Record<string, any> }
|
||||||
|
? FromSchema<DuxSchema<D>>
|
||||||
|
: D extends { initialState: infer INITIAL_STATE }
|
||||||
|
? INITIAL_STATE
|
||||||
|
: {}) &
|
||||||
|
(D extends { subduxes: Record<string, DuxConfig> }
|
||||||
|
? SubduxesState<D>
|
||||||
|
: unknown);
|
||||||
|
|
||||||
|
export function withPayload<P>(): (input: P) => { payload: P };
|
||||||
|
export function withPayload<P, A extends any[]>(
|
||||||
|
prepare: (...args: A) => P,
|
||||||
|
): (...input: A) => { payload: P };
|
||||||
|
export function withPayload(prepare = (input) => input) {
|
||||||
|
return (...input) => ({
|
||||||
|
payload: prepare.apply(null, input),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const withPayload: WithPayload = ((prepare) =>
|
export function expandAction(prepare, actionType?: string) {
|
||||||
(...input) => ({
|
if (typeof prepare === 'function' && prepare.type) return prepare;
|
||||||
payload: prepare ? prepare(...input) : input[0],
|
|
||||||
})) as any;
|
|
||||||
|
|
||||||
const id = (x) => x;
|
if (typeof prepare === 'function') {
|
||||||
export const createPayloadAction = <
|
return createAction(actionType, withPayload(prepare));
|
||||||
P extends any = any,
|
}
|
||||||
T extends string = string,
|
|
||||||
F extends (...args: any[]) => P = (input: P) => P,
|
if (actionType) {
|
||||||
>(
|
return createAction(actionType);
|
||||||
type: T,
|
}
|
||||||
prepare?: F,
|
}
|
||||||
) =>
|
|
||||||
createAction(
|
export function buildActions(localActions = {}, subduxes = {}) {
|
||||||
type,
|
localActions = R.mapValues(localActions, expandAction);
|
||||||
withPayload<ReturnType<F>, Parameters<F>>(prepare ?? (id as any)),
|
|
||||||
);
|
let actions: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const slice in subduxes) {
|
||||||
|
const subdux = subduxes[slice].actions;
|
||||||
|
|
||||||
|
if (!subdux) continue;
|
||||||
|
|
||||||
|
for (const a in subdux) {
|
||||||
|
if (actions[a] && subduxes[actions[a]].actions[a] !== subdux[a]) {
|
||||||
|
throw new Error(
|
||||||
|
`action '${a}' defined both in subduxes '${actions[a]}' and '${slice}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
actions[a] = slice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a in localActions) {
|
||||||
|
if (actions[a]) {
|
||||||
|
throw new Error(
|
||||||
|
`action '${a}' defined both locally and in subdux '${actions[a]}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return R.mergeAll([
|
||||||
|
localActions,
|
||||||
|
...Object.values(subduxes).map(R.pathOr<any, any>(['actions'], {})),
|
||||||
|
]) as any;
|
||||||
|
}
|
||||||
|
@ -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 { buildEffectsMiddleware } from './effects.js';
|
||||||
import Updux, { createAction } from './index.js';
|
import { createAction } from './index.js';
|
||||||
|
|
||||||
|
test('addEffect', () => {
|
||||||
|
const dux = new Updux({});
|
||||||
|
|
||||||
|
dux.addEffect((api) => (next) => (action) => { });
|
||||||
|
});
|
||||||
|
|
||||||
test('buildEffectsMiddleware', () => {
|
test('buildEffectsMiddleware', () => {
|
||||||
let seen = 0;
|
let seen = 0;
|
||||||
@ -39,7 +48,7 @@ test('buildEffectsMiddleware', () => {
|
|||||||
|
|
||||||
expect(seen).toEqual(0);
|
expect(seen).toEqual(0);
|
||||||
const dispatch = vi.fn();
|
const dispatch = vi.fn();
|
||||||
mw({ getState: () => 'the state', dispatch })(() => {})({
|
mw({ getState: () => 'the state', dispatch })(() => { })({
|
||||||
type: 'noop',
|
type: 'noop',
|
||||||
});
|
});
|
||||||
expect(seen).toEqual(1);
|
expect(seen).toEqual(1);
|
||||||
@ -135,8 +144,8 @@ test('addEffect with actionCreator', () => {
|
|||||||
test('addEffect with function', () => {
|
test('addEffect with function', () => {
|
||||||
const dux = new Updux({
|
const dux = new Updux({
|
||||||
actions: {
|
actions: {
|
||||||
foo: () => {},
|
foo: () => { },
|
||||||
bar: () => {},
|
bar: () => { },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -158,5 +167,32 @@ test('addEffect with function', () => {
|
|||||||
expect(spy).toHaveBeenCalled();
|
expect(spy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('catchall addEffect', () => {
|
||||||
|
const dux = new Updux({
|
||||||
|
initialState: {
|
||||||
|
a: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const spy = vi.fn();
|
||||||
|
|
||||||
|
dux.addEffect((api) => next => action => {
|
||||||
|
expectTypeOf(api.getState()).toMatchTypeOf<{
|
||||||
|
a: number;
|
||||||
|
}>();
|
||||||
|
spy();
|
||||||
|
next(action);
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = dux.createStore();
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
store.dispatch({ type: 'noop' });
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
// TODO subdux effects
|
// TODO subdux effects
|
||||||
// TODO allow to subscribe / unsubscribe effects?
|
// TODO allow to subscribe / unsubscribe effects?
|
||||||
|
@ -1,46 +1,32 @@
|
|||||||
import { AnyAction } from '@reduxjs/toolkit';
|
import { AnyAction } from '@reduxjs/toolkit';
|
||||||
import { MiddlewareAPI } from '@reduxjs/toolkit';
|
|
||||||
import { Dispatch } from '@reduxjs/toolkit';
|
import { Dispatch } from '@reduxjs/toolkit';
|
||||||
|
import { MiddlewareAPI } from '@reduxjs/toolkit';
|
||||||
|
import { AugmentedMiddlewareAPI, augmentGetState } from './createStore.js';
|
||||||
|
|
||||||
export interface EffectMiddleware<S = any, D extends Dispatch = Dispatch> {
|
//const composeMw = (mws) => (api) => (originalNext) =>
|
||||||
(api: MiddlewareAPI<D, S>): (
|
// mws.reduceRight((next, mw) => mw(api)(next), originalNext);
|
||||||
|
|
||||||
|
export interface EffectMiddleware<D> {
|
||||||
|
(api: AugmentedMiddlewareAPI<D>): (
|
||||||
next: Dispatch<AnyAction>,
|
next: Dispatch<AnyAction>,
|
||||||
) => (action: AnyAction) => any;
|
) => (action: AnyAction) => any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const composeMw = (mws) => (api) => (originalNext) =>
|
export function buildEffects(localEffects, subduxes = {}) {
|
||||||
mws.reduceRight((next, mw) => mw(api)(next), originalNext);
|
return [
|
||||||
|
...localEffects,
|
||||||
export const augmentGetState = (originalGetState, selectors) => {
|
...(Object.entries(subduxes) as any).flatMap(([slice, { effects }]) => {
|
||||||
const getState = () => originalGetState();
|
if (!effects) return [];
|
||||||
for (const s in selectors) {
|
return effects.map(
|
||||||
getState[s] = (...args) => {
|
(effect) => (api) =>
|
||||||
let result = selectors[s](originalGetState());
|
effect({
|
||||||
if (typeof result === 'function') return result(...args);
|
...api,
|
||||||
return result;
|
getState: () => api.getState()[slice],
|
||||||
};
|
}),
|
||||||
}
|
);
|
||||||
return getState;
|
}),
|
||||||
};
|
];
|
||||||
|
}
|
||||||
const augmentDispatch = (originalDispatch, actions) => {
|
|
||||||
const dispatch = (action) => originalDispatch(action);
|
|
||||||
|
|
||||||
for (const a in actions) {
|
|
||||||
dispatch[a] = (...args) => dispatch(actions[a](...args));
|
|
||||||
}
|
|
||||||
return dispatch;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const augmentMiddlewareApi = (api, actions, selectors) => {
|
|
||||||
return {
|
|
||||||
...api,
|
|
||||||
getState: augmentGetState(api.getState, selectors),
|
|
||||||
dispatch: augmentDispatch(api.dispatch, actions),
|
|
||||||
actions,
|
|
||||||
selectors,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildEffectsMiddleware(
|
export function buildEffectsMiddleware(
|
||||||
effects = [],
|
effects = [],
|
||||||
@ -57,3 +43,22 @@ export function buildEffectsMiddleware(
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const augmentMiddlewareApi = (api, actions, selectors) => {
|
||||||
|
return {
|
||||||
|
...api,
|
||||||
|
getState: augmentGetState(api.getState, selectors),
|
||||||
|
dispatch: augmentDispatch(api.dispatch, actions),
|
||||||
|
actions,
|
||||||
|
selectors,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const augmentDispatch = (originalDispatch, actions) => {
|
||||||
|
const dispatch = (action) => originalDispatch(action);
|
||||||
|
|
||||||
|
for (const a in actions) {
|
||||||
|
dispatch[a] = (...args) => dispatch(actions[a](...args));
|
||||||
|
}
|
||||||
|
return dispatch;
|
||||||
|
};
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
|
export { withPayload } from './actions.js';
|
||||||
import Updux from './Updux.js';
|
import Updux from './Updux.js';
|
||||||
|
export { createAction } from '@reduxjs/toolkit';
|
||||||
export { withPayload, createAction, createPayloadAction } from './actions.js';
|
|
||||||
|
|
||||||
export default Updux;
|
export default Updux;
|
||||||
|
@ -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';
|
import Updux from './Updux.js';
|
||||||
|
|
||||||
|
test('default', () => {
|
||||||
|
const dux = new Updux({});
|
||||||
|
|
||||||
|
expect(dux.initialState).toBeTypeOf('object');
|
||||||
|
expect(dux.initialState).toEqual({});
|
||||||
|
|
||||||
|
expectTypeOf(dux.initialState).toEqualTypeOf<{}>();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('number', () => {
|
||||||
|
const dux = new Updux({ initialState: 3 });
|
||||||
|
|
||||||
|
expect(dux.initialState).toBeTypeOf('number');
|
||||||
|
expect(dux.initialState).toEqual(3);
|
||||||
|
|
||||||
|
expectTypeOf(dux.initialState).toEqualTypeOf<number>();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single dux', () => {
|
||||||
|
const foo = new Updux({
|
||||||
|
initialState: { a: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(foo.initialState).toEqual({ a: 1 });
|
||||||
|
expectTypeOf(foo.initialState).toEqualTypeOf<{ a: number }>();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no initialState for subdux', () => {
|
||||||
|
const subduxes = {
|
||||||
|
bar: new Updux({}).toDux(),
|
||||||
|
baz: new Updux({ initialState: 'potato' }).toDux(),
|
||||||
|
};
|
||||||
|
const dux = new Updux({
|
||||||
|
subduxes,
|
||||||
|
}).toDux();
|
||||||
|
|
||||||
|
expectTypeOf(dux.initialState).toEqualTypeOf<{
|
||||||
|
bar: {};
|
||||||
|
baz: string;
|
||||||
|
}>();
|
||||||
|
expect(dux.initialState).toEqual({ bar: {}, baz: 'potato' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('basic', () => {
|
||||||
|
expect(
|
||||||
|
buildInitialState(
|
||||||
|
{ a: 1 },
|
||||||
|
{ b: { initialState: { c: 2 } }, d: { initialState: 'e' } },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
a: 1,
|
||||||
|
b: { c: 2 },
|
||||||
|
d: 'e',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throw if subduxes and initialState is not an object', () => {
|
||||||
|
expect(() => {
|
||||||
|
buildInitialState(3, { bar: 'foo' });
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
const bar = new Updux({ initialState: 123 });
|
const bar = new Updux({ initialState: 123 });
|
||||||
|
|
||||||
const foo = new Updux({
|
const foo = new Updux({
|
||||||
initialState: { root: 'abc' },
|
initialState: { root: 'abc' },
|
||||||
subduxes: {
|
subduxes: {
|
||||||
bar,
|
bar: bar.asDux,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
test('default', () => {
|
|
||||||
const { initialState } = new Updux({});
|
|
||||||
|
|
||||||
expect(initialState).toBeTypeOf('object');
|
|
||||||
expect(initialState).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('number', () => {
|
|
||||||
const { initialState } = new Updux({ initialState: 3 });
|
|
||||||
|
|
||||||
expect(initialState).toBeTypeOf('number');
|
|
||||||
expect(initialState).toEqual(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initialState to createStore', () => {
|
test('initialState to createStore', () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
a: 1,
|
a: 1,
|
||||||
@ -42,14 +91,6 @@ test('initialState to createStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('single dux', () => {
|
|
||||||
const foo = new Updux({
|
|
||||||
initialState: { a: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(foo.initialState).toEqual({ a: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO add 'check for no todo eslint rule'
|
// TODO add 'check for no todo eslint rule'
|
||||||
test('initialState value', () => {
|
test('initialState value', () => {
|
||||||
expect(foo.initialState).toEqual({
|
expect(foo.initialState).toEqual({
|
||||||
@ -57,27 +98,12 @@ test('initialState value', () => {
|
|||||||
bar: 123,
|
bar: 123,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectType<{
|
expectTypeOf(foo.initialState.bar).toMatchTypeOf<number>();
|
||||||
|
|
||||||
|
expectTypeOf(foo.initialState).toMatchTypeOf<{
|
||||||
root: string;
|
root: string;
|
||||||
bar: number;
|
bar: number;
|
||||||
}>(foo.initialState);
|
}>();
|
||||||
});
|
|
||||||
|
|
||||||
test('no initialState', () => {
|
|
||||||
const dux = new Updux({});
|
|
||||||
expectType<{}>(dux.initialState);
|
|
||||||
expect(dux.initialState).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no initialState for subdux', () => {
|
|
||||||
const dux = new Updux({
|
|
||||||
subduxes: {
|
|
||||||
bar: new Updux({}),
|
|
||||||
baz: new Updux({ initialState: 'potato' }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expectType<{ bar: {}; baz: string }>(dux.initialState);
|
|
||||||
expect(dux.initialState).toEqual({ bar: {}, baz: 'potato' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.todo('splat initialState', async () => {
|
test.todo('splat initialState', async () => {
|
16
src/initialState.ts
Normal file
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 { test, expect } from 'vitest';
|
||||||
|
import Updux from './Updux.js';
|
||||||
import Updux, { createAction } from './index.js';
|
|
||||||
|
|
||||||
test('set a mutation', () => {
|
test('set a mutation', () => {
|
||||||
const dux = new Updux({
|
const dux = new Updux({
|
||||||
initialState: 'potato',
|
initialState: 'potato',
|
||||||
actions: {
|
actions: {
|
||||||
foo: (x) => ({ x }),
|
foo: (x: string) => ({ x }),
|
||||||
bar: 0,
|
bar: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -14,6 +15,8 @@ test('set a mutation', () => {
|
|||||||
let didIt = false;
|
let didIt = false;
|
||||||
|
|
||||||
dux.addMutation(dux.actions.foo, (payload, action) => () => {
|
dux.addMutation(dux.actions.foo, (payload, action) => () => {
|
||||||
|
expectTypeOf(payload).toMatchTypeOf<{ x: string }>();
|
||||||
|
|
||||||
didIt = true;
|
didIt = true;
|
||||||
expect(payload).toEqual({ x: 'hello ' });
|
expect(payload).toEqual({ x: 'hello ' });
|
||||||
expect(action).toEqual(dux.actions.foo('hello '));
|
expect(action).toEqual(dux.actions.foo('hello '));
|
||||||
@ -78,3 +81,21 @@ test('mutation of a subdux', () => {
|
|||||||
expect(foo.reducer(undefined, baz())).toHaveProperty('bar', 1);
|
expect(foo.reducer(undefined, baz())).toHaveProperty('bar', 1);
|
||||||
expect(foo.reducer(undefined, stopit())).toHaveProperty('bar', 0);
|
expect(foo.reducer(undefined, stopit())).toHaveProperty('bar', 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('actionType as string', () => {
|
||||||
|
const dux = new Updux({
|
||||||
|
actions: {
|
||||||
|
doIt: (id: number) => id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dux.addMutation('doIt', (payload) => (state) => {
|
||||||
|
expectTypeOf(payload).toMatchTypeOf<number>();
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
// @ts-ignore
|
||||||
|
dux.addMutation('unknown', () => (x) => x);
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
@ -46,11 +46,11 @@ test('subdux reactions', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const foo = new Updux({ actions: { notInBar: 0 }, subduxes: { bar } });
|
|
||||||
|
|
||||||
// TODO immer that stuff
|
// TODO immer that stuff
|
||||||
bar.addMutation(foo.actions.inc, () => (state) => state + 1);
|
bar.addMutation(bar.actions.inc, () => (state) => {
|
||||||
bar.addMutation(foo.actions.reset, () => (state) => 0);
|
return state + 1
|
||||||
|
});
|
||||||
|
bar.addMutation(bar.actions.reset, () => (state) => 0);
|
||||||
|
|
||||||
let seen = 0;
|
let seen = 0;
|
||||||
bar.addReaction((api) => (state, _previous, unsubscribe) => {
|
bar.addReaction((api) => (state, _previous, unsubscribe) => {
|
||||||
@ -62,6 +62,11 @@ test('subdux reactions', () => {
|
|||||||
api.dispatch.reset();
|
api.dispatch.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const foo = new Updux({
|
||||||
|
actions: { notInBar: 0 },
|
||||||
|
subduxes: { bar: bar.asDux },
|
||||||
|
});
|
||||||
|
|
||||||
const store = foo.createStore();
|
const store = foo.createStore();
|
||||||
|
|
||||||
store.dispatch.inc();
|
store.dispatch.inc();
|
||||||
@ -69,7 +74,9 @@ test('subdux reactions', () => {
|
|||||||
expect(store.getState()).toEqual({ bar: 1 });
|
expect(store.getState()).toEqual({ bar: 1 });
|
||||||
expect(store.getState.getIt()).toEqual(1);
|
expect(store.getState.getIt()).toEqual(1);
|
||||||
store.dispatch.inc();
|
store.dispatch.inc();
|
||||||
|
expect(seen).toEqual(2);
|
||||||
store.dispatch.inc();
|
store.dispatch.inc();
|
||||||
|
expect(seen).toEqual(3);
|
||||||
|
|
||||||
expect(store.getState.getIt()).toEqual(0); // we've been reset
|
expect(store.getState.getIt()).toEqual(0); // we've been reset
|
||||||
|
|
||||||
@ -78,5 +85,6 @@ test('subdux reactions', () => {
|
|||||||
store.dispatch.inc();
|
store.dispatch.inc();
|
||||||
store.dispatch.inc();
|
store.dispatch.inc();
|
||||||
|
|
||||||
|
expect(seen).toEqual(3);
|
||||||
expect(store.getState.getIt()).toEqual(4); // we've unsubscribed
|
expect(store.getState.getIt()).toEqual(4); // we've unsubscribed
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
import { test, expect } from 'vitest';
|
import { test, expect } from 'vitest';
|
||||||
|
import { withPayload } from './actions.js';
|
||||||
|
|
||||||
import { buildReducer } from './reducer.js';
|
import { buildReducer } from './reducer.js';
|
||||||
import Updux from './Updux.js';
|
import Updux from './Updux.js';
|
||||||
@ -22,10 +24,70 @@ test('buildReducer, mutation', () => {
|
|||||||
expect(reducer(undefined, { type: 'inc' })).toEqual(2);
|
expect(reducer(undefined, { type: 'inc' })).toEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.todo('basic reducer', () => {
|
test('basic reducer', () => {
|
||||||
const dux = new Updux({ initialState: { a: 3 } });
|
const dux = new Updux({
|
||||||
|
initialState: { a: 3 },
|
||||||
|
actions: {
|
||||||
|
add: (x: number) => x,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dux.addMutation(dux.actions.add, (incr) => (state) => ({
|
||||||
|
a: state.a + incr,
|
||||||
|
}));
|
||||||
|
|
||||||
expect(dux.reducer).toBeTypeOf('function');
|
expect(dux.reducer).toBeTypeOf('function');
|
||||||
|
|
||||||
expect(dux.reducer({ a: 1 }, { type: 'foo' })).toMatchObject({ a: 1 }); // noop
|
expect(dux.reducer({ a: 1 }, { type: 'noop' })).toMatchObject({ a: 1 }); // noop
|
||||||
|
|
||||||
|
expect(dux.reducer({ a: 1 }, dux.actions.add(2))).toMatchObject({ a: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaultMutation', () => {
|
||||||
|
const dux = new Updux({
|
||||||
|
initialState: { a: 0, b: 0 },
|
||||||
|
actions: {
|
||||||
|
add: (x: number) => x,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dux.addMutation(dux.actions.add, (incr) => (state) => ({
|
||||||
|
...state,
|
||||||
|
a: state.a + incr,
|
||||||
|
}));
|
||||||
|
|
||||||
|
dux.addDefaultMutation((payload) => (state) => ({
|
||||||
|
...state,
|
||||||
|
b: state.b + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(dux.reducer({ a: 0, b: 0 }, { type: 'noop' })).toMatchObject({
|
||||||
|
a: 0,
|
||||||
|
b: 1,
|
||||||
|
}); // noop catches the default mutation
|
||||||
|
|
||||||
|
expect(dux.reducer({ a: 1, b: 0 }, dux.actions.add(2))).toMatchObject({
|
||||||
|
a: 3,
|
||||||
|
b: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subduxes mutations', () => {
|
||||||
|
const sub1 = new Updux({
|
||||||
|
initialState: 0,
|
||||||
|
actions: {
|
||||||
|
sub1action: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
sub1.addMutation(sub1.actions.sub1action, () => (state) => state + 1);
|
||||||
|
|
||||||
|
const dux = new Updux({
|
||||||
|
subduxes: {
|
||||||
|
sub1: sub1.asDux,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dux.reducer(undefined, dux.actions.sub1action())).toMatchObject({
|
||||||
|
sub1: 1,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
100
src/reducer.ts
100
src/reducer.ts
@ -1,10 +1,8 @@
|
|||||||
import { Action, ActionCreator, createAction } from '@reduxjs/toolkit';
|
import { Action } from '@reduxjs/toolkit';
|
||||||
import { BaseActionCreator } from '@reduxjs/toolkit/dist/createAction.js';
|
|
||||||
import * as R from 'remeda';
|
import * as R from 'remeda';
|
||||||
import { Dux } from './types.js';
|
import { DuxConfig } from './types.js';
|
||||||
import { Mutation } from './Updux.js';
|
import { Mutation } from './Updux.js';
|
||||||
import u from '@yanick/updeep-remeda';
|
import u from '@yanick/updeep-remeda';
|
||||||
import { produce } from 'immer';
|
|
||||||
|
|
||||||
export type MutationCase = {
|
export type MutationCase = {
|
||||||
matcher: (action: Action) => boolean;
|
matcher: (action: Action) => boolean;
|
||||||
@ -13,10 +11,10 @@ export type MutationCase = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function buildReducer(
|
export function buildReducer(
|
||||||
initialStateState: any,
|
initialStateState: unknown,
|
||||||
mutations: MutationCase[] = [],
|
mutations: MutationCase[] = [],
|
||||||
defaultMutation?: Omit<MutationCase, 'matcher'>,
|
defaultMutation?: Omit<MutationCase, 'matcher'>,
|
||||||
subduxes: Record<string, Dux> = {},
|
subduxes: Record<string, DuxConfig> = {},
|
||||||
) {
|
) {
|
||||||
const subReducers = R.mapValues(subduxes, R.prop('reducer'));
|
const subReducers = R.mapValues(subduxes, R.prop('reducer'));
|
||||||
|
|
||||||
@ -24,72 +22,42 @@ export function buildReducer(
|
|||||||
// TODO defaultMutation
|
// TODO defaultMutation
|
||||||
//
|
//
|
||||||
const reducer = (state = initialStateState, action: Action) => {
|
const reducer = (state = initialStateState, action: Action) => {
|
||||||
const orig = state;
|
if (!action?.type) throw new Error('reducer called with a bad action');
|
||||||
if (!action?.type)
|
|
||||||
throw new Error('upreducer called with a bad action');
|
|
||||||
|
|
||||||
let terminal = false;
|
let active = mutations.filter(({ matcher }) => matcher(action));
|
||||||
let didSomething = false;
|
|
||||||
|
|
||||||
mutations
|
if (active.length === 0 && defaultMutation)
|
||||||
.filter(({ matcher }) => matcher(action))
|
active.push(defaultMutation as any);
|
||||||
.forEach(({ mutation, terminal: t }) => {
|
|
||||||
if (t) terminal = true;
|
|
||||||
didSomething = true;
|
|
||||||
state = produce(
|
|
||||||
state,
|
|
||||||
mutation((action as any).payload, action),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!didSomething && defaultMutation) {
|
if (
|
||||||
if (defaultMutation.terminal) terminal = true;
|
!active.some(R.prop<any, any>('terminal')) &&
|
||||||
|
Object.values(subReducers).length > 0
|
||||||
state = defaultMutation.mutation(
|
) {
|
||||||
(action as any).payload,
|
active.push({
|
||||||
action,
|
mutation: (payload, action) => (state) => {
|
||||||
)(state);
|
return u(
|
||||||
|
state,
|
||||||
|
R.mapValues(
|
||||||
|
subReducers,
|
||||||
|
(reducer, slice) => (state) => {
|
||||||
|
return (reducer as any)(state, action);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!terminal && Object.keys(subduxes).length > 0) {
|
// frozen objects don't play well with immer
|
||||||
// subduxes
|
// if (Object.isFrozen(state)) {
|
||||||
state = u.update(
|
// state = { ...(state as any) };
|
||||||
state,
|
// }
|
||||||
R.mapValues(subReducers, (reducer, slice) =>
|
return active.reduce(
|
||||||
(reducer as any)(state[slice], action),
|
(state, { mutation }) =>
|
||||||
),
|
mutation((action as any).payload, action)(state),
|
||||||
);
|
state,
|
||||||
}
|
);
|
||||||
|
|
||||||
return state;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return reducer;
|
return reducer;
|
||||||
|
|
||||||
/*
|
|
||||||
if (subReducers) {
|
|
||||||
if (subduxes['*']) {
|
|
||||||
newState = u.updateIn(
|
|
||||||
'*',
|
|
||||||
subduxes['*'].upreducer(action),
|
|
||||||
newState,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const update = mapValues(subReducers, (upReducer) =>
|
|
||||||
upReducer(action),
|
|
||||||
);
|
|
||||||
|
|
||||||
newState = u(update, newState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const a = mutations[action.type] || mutations['+'];
|
|
||||||
|
|
||||||
if (!a) return newState;
|
|
||||||
|
|
||||||
return a(action.payload, action)(newState);
|
|
||||||
};
|
|
||||||
|
|
||||||
return wrapper ? wrapper(upreducer) : upreducer;
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
31
src/schema.test.ts
Normal file
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';
|
import Updux, { createAction } from './index.js';
|
||||||
|
|
||||||
test('basic selectors', () => {
|
describe('basic selectors', () => {
|
||||||
type State = { x: number };
|
type State = { x: number };
|
||||||
|
|
||||||
const foo = new Updux({
|
const foo = new Updux({
|
||||||
@ -10,7 +10,7 @@ test('basic selectors', () => {
|
|||||||
x: 1,
|
x: 1,
|
||||||
},
|
},
|
||||||
selectors: {
|
selectors: {
|
||||||
getX: ({ x }: State) => x,
|
getX: ({ x }) => x,
|
||||||
},
|
},
|
||||||
subduxes: {
|
subduxes: {
|
||||||
bar: new Updux({
|
bar: new Updux({
|
||||||
@ -19,8 +19,8 @@ test('basic selectors', () => {
|
|||||||
getY: ({ y }: { y: number }) => y,
|
getY: ({ y }: { y: number }) => y,
|
||||||
getYPlus:
|
getYPlus:
|
||||||
({ y }) =>
|
({ y }) =>
|
||||||
(incr: number) =>
|
(incr: number) =>
|
||||||
(y + incr) as number,
|
(y + incr) as number,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@ -31,12 +31,16 @@ test('basic selectors', () => {
|
|||||||
bar: { y: 3 },
|
bar: { y: 3 },
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(foo.selectors.getY(sample)).toBe(3);
|
test('updux selectors', () => {
|
||||||
expect(foo.selectors.getX(sample)).toBe(4);
|
expect(foo.selectors.getX(sample)).toBe(4);
|
||||||
|
expect(foo.selectors.getY(sample)).toBe(3);
|
||||||
|
|
||||||
expect(foo.selectors.getYPlus(sample)(3)).toBe(6);
|
expect(foo.selectors.getYPlus(sample)(3)).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
const store = foo.createStore();
|
test.todo('store selectors', () => {
|
||||||
expect(store.getState.getY()).toBe(2);
|
const store = foo.createStore();
|
||||||
expect(store.getState.getYPlus(3)).toBe(5);
|
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 Updux, { createAction, withPayload } from './index.js';
|
||||||
import u from '@yanick/updeep-remeda';
|
import u from '@yanick/updeep-remeda';
|
||||||
|
import { expectTypeOf } from 'expect-type';
|
||||||
export const expectType = <T>(value: T) => value;
|
|
||||||
|
|
||||||
test('initialState state', () => {
|
test('initialState state', () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
@ -12,10 +11,10 @@ test('initialState state', () => {
|
|||||||
initialState,
|
initialState,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectType<{
|
expectTypeOf(dux.initialState).toMatchTypeOf<{
|
||||||
next_id: number;
|
next_id: number;
|
||||||
todos: unknown[];
|
todos: unknown[];
|
||||||
}>(dux.initialState);
|
}>();
|
||||||
|
|
||||||
expect(dux.initialState).toEqual(initialState);
|
expect(dux.initialState).toEqual(initialState);
|
||||||
|
|
||||||
|
11
src/types.test.ts
Normal file
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';
|
import type { FromSchema } from 'json-schema-to-ts';
|
||||||
|
import Updux from './Updux.js';
|
||||||
export type Dux<
|
export type { FromSchema } from 'json-schema-to-ts';
|
||||||
STATE = any,
|
|
||||||
ACTIONS extends Record<string, ActionCreator<string>> = {},
|
|
||||||
> = Partial<{
|
|
||||||
initialState: STATE;
|
|
||||||
actions: ACTIONS;
|
|
||||||
selectors: Record<string, (state: STATE) => any>;
|
|
||||||
reducer: (
|
|
||||||
state: STATE,
|
|
||||||
action: ReturnType<ACTIONS[keyof ACTIONS]>,
|
|
||||||
) => STATE;
|
|
||||||
effects: Middleware[];
|
|
||||||
reactions: ((...args: any[]) => void)[];
|
|
||||||
}>;
|
|
||||||
|
|
||||||
type ActionsOf<DUX> = DUX extends { actions: infer A } ? A : {};
|
|
||||||
type SelectorsOf<DUX> = DUX extends { selectors: infer A } ? A : {};
|
|
||||||
|
|
||||||
export type AggregateActions<A, S> = UnionToIntersection<
|
|
||||||
ActionsOf<S[keyof S]> | A
|
|
||||||
>;
|
|
||||||
|
|
||||||
type BaseSelector<F extends (...args: any) => any, STATE> = (
|
|
||||||
state: STATE,
|
|
||||||
) => ReturnType<F>;
|
|
||||||
|
|
||||||
type BaseSelectors<S extends Record<string, any>, STATE> = {
|
|
||||||
[key in keyof S]: BaseSelector<S[key], STATE>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AggregateSelectors<
|
|
||||||
S extends Record<string, (...args: any) => any>,
|
|
||||||
SUBS extends Record<string, Dux>,
|
|
||||||
STATE = {},
|
|
||||||
> = BaseSelectors<
|
|
||||||
UnionToIntersection<SelectorsOf<SUBS[keyof SUBS]> | S>,
|
|
||||||
STATE
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type UnionToIntersection<U> = (
|
export type UnionToIntersection<U> = (
|
||||||
U extends any ? (k: U) => void : never
|
U extends any ? (k: U) => void : never
|
||||||
) extends (k: infer I) => void
|
) extends (k: infer I) => void
|
||||||
? I
|
? I
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
|
export type DuxConfig = Partial<{
|
||||||
|
initialState: any;
|
||||||
|
schema: Record<string, any>;
|
||||||
|
actions: Record<string, any>;
|
||||||
|
subduxes: Record<string, DuxConfig>;
|
||||||
|
reducer: unknown;
|
||||||
|
selectors: Record<string, any>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type DuxSchema<D extends DuxConfig> = D extends { schema: infer S }
|
||||||
|
? S
|
||||||
|
: unknown;
|
||||||
|
|
||||||
|
type SubduxesOrBust<S, K> = K extends never ? 'potato' : S;
|
||||||
|
|
||||||
|
export type SubduxesState<D> = D extends {
|
||||||
|
subduxes: infer S;
|
||||||
|
}
|
||||||
|
? S extends never
|
||||||
|
? unknown
|
||||||
|
: {
|
||||||
|
[key in keyof S]: DuxState<S[key]>;
|
||||||
|
}
|
||||||
|
: unknown;
|
||||||
|
|
||||||
|
export type DuxState<D> = (D extends { initialState: infer INITIAL_STATE }
|
||||||
|
? INITIAL_STATE
|
||||||
|
: {}) &
|
||||||
|
(D extends { subduxes: any } ? SubduxesState<D> : unknown);
|
||||||
|
46
src/types.ts.2023-08-18
Normal file
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": {
|
"compilerOptions": {
|
||||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user