state-decorator 6.6.1 → 6.8.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +477 -121
- package/impl.es.js +1 -1
- package/impl.es.js.map +1 -1
- package/impl.js +1 -1
- package/impl.js.map +1 -1
- package/index.d.ts +56 -6
- package/index.es.js +1 -1
- package/index.es.js.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/middlewares.es.js.map +1 -1
- package/middlewares.js.map +1 -1
- package/package.json +1 -1
- package/test.d.ts +56 -1
- package/test.es.js +1 -1
- package/test.es.js.map +1 -1
- package/test.js +1 -1
- package/test.js.map +1 -1
package/README.md
CHANGED
|
@@ -11,12 +11,11 @@ The StateDecorator is a set of Reacts hook that manages a complex component stat
|
|
|
11
11
|
- Deterministic state changes (clear separation of effects and side effects)
|
|
12
12
|
- Ease asynchronous actions state changes (loading states, success & error handlers, parallel actions management, optimistic updates...)
|
|
13
13
|
- Easily and efficiently share slices of state.
|
|
14
|
-
- Easily testable (
|
|
14
|
+
- Easily testable (test framework provided)
|
|
15
15
|
- Easily update state from or react to props changes
|
|
16
|
-
- Ease debugging (trace state changes)
|
|
16
|
+
- Ease debugging (trace state changes, dev tools)
|
|
17
17
|
- Improve code conciseness (no boiler plate code)
|
|
18
|
-
-
|
|
19
|
-
- Strongly typed
|
|
18
|
+
- Strongly typed (Typescript)
|
|
20
19
|
|
|
21
20
|
# 🏎️ V6: The Store
|
|
22
21
|
|
|
@@ -76,19 +75,19 @@ type Props = {
|
|
|
76
75
|
// Initial state & actions
|
|
77
76
|
|
|
78
77
|
export const getInitialState = (): State => ({
|
|
79
|
-
|
|
78
|
+
selectedUserId: null,
|
|
80
79
|
user: null,
|
|
81
80
|
});
|
|
82
81
|
|
|
83
82
|
export const userAppActions: StoreActions<State, Actions, Props> = {
|
|
84
83
|
// synchronous action (simple form)
|
|
85
|
-
selectUser: ({ state, args: [id] }) => ({ ...state,
|
|
84
|
+
selectUser: ({ state, args: [id] }) => ({ ...state, selectedUserId: id }),
|
|
86
85
|
|
|
87
86
|
// a synchronous action with side effects
|
|
88
87
|
selectAndLoadUser: {
|
|
89
88
|
// same as simple form, using aliases (use short context alias 's' instead of 'state')
|
|
90
89
|
// effects contain only immutable state changes
|
|
91
|
-
effects: ({ s, args: [id] }) => ({ ...s,
|
|
90
|
+
effects: ({ s, args: [id] }) => ({ ...s, selectedUserId: id }),
|
|
92
91
|
// side effects (ie. other effects than state changes), using "actions" alias
|
|
93
92
|
sideEffects: ({ a }) => {
|
|
94
93
|
a.loadUser();
|
|
@@ -139,12 +138,12 @@ export function App(props: Props) => {
|
|
|
139
138
|
|
|
140
139
|
# React hooks
|
|
141
140
|
|
|
142
|
-
| Hook
|
|
143
|
-
|
|
|
144
|
-
| useLocalStore
|
|
145
|
-
|
|
|
146
|
-
|
|
|
147
|
-
| useStoreSlice
|
|
141
|
+
| Hook | Purpose | Return | Component refreshed on store change | Store is destroyed on unmount |
|
|
142
|
+
| ---------------- | --------------------------------------------------- | -------------- | ----------------------------------- | ----------------------------- |
|
|
143
|
+
| useLocalStore | Create a store, binds it to the react component. | State, actions | Y | Y |
|
|
144
|
+
| useGetLocalStore | Create a store, binds it to the react component. | Store | N | Y |
|
|
145
|
+
| useStore | Binds a store instance to the react component. | State, actions | Y | N |
|
|
146
|
+
| useStoreSlice | Binds a store slice to a react component (sharing). | Slice | If slice has changed only | N |
|
|
148
147
|
|
|
149
148
|
# Initial state
|
|
150
149
|
|
|
@@ -492,63 +491,234 @@ When a hook that creates / binds a store is mounted, the **onMount** option is c
|
|
|
492
491
|
```typescript
|
|
493
492
|
import { createStore } from 'state-decorator';
|
|
494
493
|
|
|
494
|
+
// global store
|
|
495
495
|
const store = createStore(getInitialState, actionsImpl, {
|
|
496
496
|
onMount: ({ a }) => {
|
|
497
497
|
a.loadList;
|
|
498
498
|
},
|
|
499
499
|
});
|
|
500
500
|
|
|
501
|
-
|
|
501
|
+
store.init(initialProps);
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
import { createStore } from 'state-decorator';
|
|
506
|
+
|
|
507
|
+
// local store
|
|
502
508
|
function Container(p: ContainerProps) {
|
|
503
|
-
|
|
509
|
+
const store = useGetLocalStore(getInitialState, actionsImpl, p, {
|
|
510
|
+
onMount: ({ a }) => {
|
|
511
|
+
a.loadList;
|
|
512
|
+
},
|
|
513
|
+
});
|
|
504
514
|
}
|
|
505
515
|
```
|
|
506
516
|
|
|
507
517
|
If an initial action is launched each time a property changes, consider using the **onMount** flag of the **onPropsChange** entry.
|
|
508
518
|
|
|
509
|
-
|
|
519
|
+
# Global and Local Stores VS State sharing
|
|
510
520
|
|
|
511
|
-
|
|
512
|
-
- Deeper in the tree component, use _useStoreSlice_ to get a store slice.
|
|
513
|
-
- Component using slices will be refreshed only if their store slice changes.
|
|
521
|
+
There are two ways to share state and actions:
|
|
514
522
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
import { useStore, useStoreSlice, StoreActions } from 'state-decorator';
|
|
523
|
+
- Using regular props.
|
|
524
|
+
- Using state slices.
|
|
518
525
|
|
|
519
|
-
|
|
526
|
+
There are two types of stores:
|
|
527
|
+
|
|
528
|
+
- Global store:
|
|
529
|
+
|
|
530
|
+
- store is created as an instance and it is exported from the JS module.
|
|
531
|
+
- it can be imported in any other JS module.
|
|
532
|
+
- store lifespan is equal to the application one.
|
|
520
533
|
|
|
521
|
-
|
|
534
|
+
- Local store:
|
|
535
|
+
- store is created by a hook and is bound to a React component (each instance of this component owns its own store).
|
|
536
|
+
- action and props can be shared using props or sharing the store itself (see example below)
|
|
537
|
+
- store is destroyed when its React component is unmounted.
|
|
522
538
|
|
|
539
|
+
## Using props
|
|
540
|
+
|
|
541
|
+
- Bind a React component to make store state / actions available on React component.
|
|
542
|
+
- Any change in the store (state, loading actions, ...) will trigger a refresh of the bound React component.
|
|
543
|
+
- Pass state / actions using regular React props.
|
|
544
|
+
- Use React.memo to prevent unecessary React re-renders if needed.
|
|
545
|
+
|
|
546
|
+
### Global store
|
|
547
|
+
|
|
548
|
+
```typescript
|
|
549
|
+
// Declare typings & actions as above
|
|
550
|
+
|
|
551
|
+
// Create a store in a dedicated file, named GlobalStore.ts for example
|
|
523
552
|
export const store = createStore(getInitialState, userAppActions);
|
|
524
553
|
|
|
525
|
-
|
|
554
|
+
//----------------------
|
|
555
|
+
|
|
556
|
+
// In another file
|
|
557
|
+
import React, { memo } from 'react';
|
|
558
|
+
import { useStore } from 'state-decorator';
|
|
559
|
+
import store from './GlobalStore';
|
|
526
560
|
|
|
527
|
-
|
|
561
|
+
// Bind to react component
|
|
562
|
+
// Each time the store state changes, a re-render is done.
|
|
563
|
+
export function Container(props: Props) {
|
|
528
564
|
const { state, actions } = useStore(store, props);
|
|
565
|
+
return (
|
|
566
|
+
<div>
|
|
567
|
+
<Title text={state.title} />
|
|
568
|
+
<Subtitle text={state.subtitle} />
|
|
569
|
+
</div>
|
|
570
|
+
);
|
|
571
|
+
}
|
|
529
572
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
573
|
+
// Simple presentation components
|
|
574
|
+
// Use memo to prevent re-render if another part of the state has changed than "title"
|
|
575
|
+
const Title = memo(function Title(props: { title: string }) {
|
|
576
|
+
return <div>{props.text}</div>;
|
|
577
|
+
});
|
|
533
578
|
|
|
534
|
-
|
|
579
|
+
const Subtitle = memo(function Subtitle(props: { subtitle: string }) {
|
|
580
|
+
return <div>{props.subtitle}</div>;
|
|
581
|
+
});
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
### Local Store
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
import React, { memo } from 'react';
|
|
588
|
+
import { useLocalStore } from 'state-decorator';
|
|
589
|
+
|
|
590
|
+
// Declare typings & actions as above
|
|
591
|
+
|
|
592
|
+
// Create and bind to react component
|
|
593
|
+
// Each time the store state changes, a re-render is done.
|
|
594
|
+
export function Container(props: Props) {
|
|
595
|
+
const { state, actions } = useLocalStore(getInitialState, userAppActions, props);
|
|
596
|
+
return (
|
|
597
|
+
<div>
|
|
598
|
+
<Title text={state.title} />
|
|
599
|
+
<Subtitle text={state.subtitle} />
|
|
600
|
+
</div>
|
|
601
|
+
);
|
|
535
602
|
}
|
|
536
603
|
|
|
537
|
-
//
|
|
538
|
-
//
|
|
604
|
+
// Simple presentation components
|
|
605
|
+
// Use memo to prevent re-render if another part of the state has changed than "title"
|
|
606
|
+
const Title = memo(function Title(props: { title: string }) {
|
|
607
|
+
return <div>{props.title}</div>;
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const Subtitle = memo(function Subtitle(props: { subtitle: string }) {
|
|
611
|
+
return <div>{props.subtitle}</div>;
|
|
612
|
+
});
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
## State slices
|
|
539
616
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
617
|
+
Using React props implies useless re-renders and optionally memoization.
|
|
618
|
+
|
|
619
|
+
To prevent this, React provides [context](https://en.reactjs.org/docs/context.html) hooks.
|
|
620
|
+
|
|
621
|
+
The problem is that if the context contains a complex state with lots of props, if **any** of these props is changing, **all** components that uses this context will be re-rendered.
|
|
622
|
+
|
|
623
|
+
To overcome this problem, the StateDecorator provides a _useStoreSlice_ and _useStoreContextSlice_ hook that allow to define and extracts a slice of the state and trigger a re-render of the React component only if slice has changed.
|
|
624
|
+
|
|
625
|
+
[](https://codesandbox.io/s/slices-v6-eg471?file=/src/SliceView.tsx)
|
|
626
|
+
|
|
627
|
+
## Global Store
|
|
628
|
+
|
|
629
|
+
```typescript
|
|
630
|
+
// Declare typings & actions as above
|
|
631
|
+
|
|
632
|
+
// Create a store in a dedicated file, named GlobalStore.ts for example
|
|
633
|
+
export const store = createStore(getInitialState, userAppActions);
|
|
634
|
+
|
|
635
|
+
//----------------------
|
|
636
|
+
|
|
637
|
+
import React, { memo } from 'react';
|
|
638
|
+
import { useStoreSlice } from 'state-decorator';
|
|
639
|
+
import store from './GlobalStore';
|
|
640
|
+
|
|
641
|
+
export function Container(props: Props) {
|
|
642
|
+
return (
|
|
643
|
+
<div>
|
|
644
|
+
<Title />
|
|
645
|
+
<Subtitle />
|
|
646
|
+
</div>
|
|
647
|
+
);
|
|
543
648
|
}
|
|
544
649
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
650
|
+
function Title() {
|
|
651
|
+
// extracts the "title" slice
|
|
652
|
+
// component is refresh only if title is changed in the store
|
|
653
|
+
const { title } = useStoreSlice(store, ['title']);
|
|
654
|
+
return <div>{title}</div>;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function Subtitle(props: { subtitle: string }) {
|
|
658
|
+
const { subtitle } = useStoreSlice(store, ['subtitle']);
|
|
659
|
+
return <div>{subtitle}</div>;
|
|
548
660
|
}
|
|
549
661
|
```
|
|
550
662
|
|
|
551
|
-
|
|
663
|
+
## Local Store
|
|
664
|
+
|
|
665
|
+
```typescript
|
|
666
|
+
// ------- stores/MyStore.ts --------
|
|
667
|
+
|
|
668
|
+
import React from 'react';
|
|
669
|
+
import { useGetLocalStore, useStoreContextSlice } from 'state-decorator';
|
|
670
|
+
|
|
671
|
+
// Declare typings & actions as above
|
|
672
|
+
|
|
673
|
+
// Create a store context
|
|
674
|
+
|
|
675
|
+
type StoreContextProps = StoreApi<State, Actions, Props>;
|
|
676
|
+
|
|
677
|
+
export const StoreContext = createContext<StoreContextProps>(null);
|
|
678
|
+
|
|
679
|
+
// The StoreContext will allow to access to the store
|
|
680
|
+
// But this context is NOT refreshed if the store state is changed
|
|
681
|
+
export function StoreContextProvider(p: { children: any; propIn: string }) {
|
|
682
|
+
const { children, ...props } = p;
|
|
683
|
+
const store = useGetLocalStore(getInitialState, actionsImpl, props);
|
|
684
|
+
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// In a container file ------------------------
|
|
688
|
+
|
|
689
|
+
import { StoreContextProvider } from 'stores/MyStore';
|
|
690
|
+
|
|
691
|
+
// If Container is destroyed store is destroyed
|
|
692
|
+
export function Container(props: Props) {
|
|
693
|
+
return (
|
|
694
|
+
<StoreContextProvider {...props}>
|
|
695
|
+
<SubComponent />
|
|
696
|
+
<SubComponent2 />
|
|
697
|
+
</StoreContextProvider>
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Component deeper in the component tree...
|
|
702
|
+
|
|
703
|
+
import { StoreContext } from 'stores/MyStore';
|
|
704
|
+
|
|
705
|
+
function Title() {
|
|
706
|
+
// extracts the "title" slice
|
|
707
|
+
// component is refresh only if title is changed in the store
|
|
708
|
+
const { title } = useStoreContextSlice(StoreContext, ['title']);
|
|
709
|
+
|
|
710
|
+
// same as
|
|
711
|
+
// const store = useContext(StoreContext)
|
|
712
|
+
// const { title } = useStoreSlice(store, ['title']);
|
|
713
|
+
|
|
714
|
+
return <div>{title}</div>;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function Subtitle(props: { subtitle: string }) {
|
|
718
|
+
const { subtitle } = useStoreContextSlice(StoreContext, ['subtitle']);
|
|
719
|
+
return <div>{subtitle}</div>;
|
|
720
|
+
}
|
|
721
|
+
```
|
|
552
722
|
|
|
553
723
|
# Global configuration
|
|
554
724
|
|
|
@@ -571,7 +741,7 @@ setGlobalConfig({
|
|
|
571
741
|
notifyError: undefined,
|
|
572
742
|
// Notification function injected in side effects action context (success and error) to notify warning
|
|
573
743
|
notifyWarning: undefined,
|
|
574
|
-
// Function called to return common error message if error message is not provided or error not managed in action
|
|
744
|
+
// Function called to return common error message if error message is not provided or error not managed in action. To override locally in action, provide a function that returns _null_.
|
|
575
745
|
getErrorMessage: undefined,
|
|
576
746
|
});
|
|
577
747
|
```
|
|
@@ -812,42 +982,138 @@ function Container(props: Props) {
|
|
|
812
982
|
# Unit testing
|
|
813
983
|
|
|
814
984
|
- The state decorator provides a testing framework for stores.
|
|
815
|
-
- The base principle is that a mock store is immutable
|
|
816
|
-
-
|
|
817
|
-
-
|
|
818
|
-
|
|
819
|
-
|
|
985
|
+
- The base principle is that a mock store is immutable:
|
|
986
|
+
- a new store is created each time state or props are set.
|
|
987
|
+
- it allows to share mock store across tests.
|
|
988
|
+
- The API is chainable: functions are returning a mock store, excepting test functions.
|
|
989
|
+
- On a mock store, we can test the store internals after initialiation, after inbound props have changed, after actions are called.
|
|
820
990
|
|
|
821
991
|
## Getting started
|
|
822
992
|
|
|
993
|
+
1. Create and setup a mock store (_createMockFromStore_)
|
|
994
|
+
2. Test store after initialization (_onInit_)
|
|
995
|
+
3. Test store after props changes (_onPropsChange_)
|
|
996
|
+
4. Test each action:
|
|
997
|
+
1. Get mock store action (_getAction_)
|
|
998
|
+
2. setup store state/prop (_setPartialState_, _setPartialProps_)
|
|
999
|
+
3. if action is asynchronous, you can mock promise (_promiseResolves_, _promiseRejects_)
|
|
1000
|
+
4. call action with params (_call_)
|
|
1001
|
+
5. await response to test state/actions called...
|
|
1002
|
+
|
|
1003
|
+
## Example
|
|
1004
|
+
|
|
1005
|
+
The store definition is available at [TodoStore.ts](https://github.com/GlobalSport/state-decorator/blob/develop/tests/fixtures/TodoStore.ts).
|
|
1006
|
+
|
|
1007
|
+
The test is available at [Todo.test.ts](https://github.com/GlobalSport/state-decorator/blob/develop/tests/Todo.test.ts).
|
|
1008
|
+
|
|
823
1009
|
```typescript
|
|
824
1010
|
import { createMockFromStore } from 'state-decorator/test';
|
|
825
1011
|
|
|
826
|
-
import
|
|
1012
|
+
import todoStore from './fixtures/TodoStore';
|
|
827
1013
|
|
|
828
1014
|
describe('Todo', () => {
|
|
829
1015
|
// create a mock store and setup initial state
|
|
830
|
-
// state / props can be set in full form or partial form
|
|
831
1016
|
// this store can be shared accross tests because it is immutable
|
|
832
|
-
const store = createMockFromStore(todoStore
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1017
|
+
const store = createMockFromStore(todoStore)
|
|
1018
|
+
// or
|
|
1019
|
+
// const store = createMockStore(getInitialState, actions, props, options)
|
|
1020
|
+
// initial state / props can be set in full form or partial form
|
|
1021
|
+
.setPartialState({
|
|
1022
|
+
idCount: 3,
|
|
1023
|
+
todoIds: ['id', 'id2'],
|
|
1024
|
+
todoMap: {
|
|
1025
|
+
id: {
|
|
1026
|
+
id: 'id',
|
|
1027
|
+
completed: true,
|
|
1028
|
+
title: 'todo',
|
|
1029
|
+
},
|
|
1030
|
+
id2: {
|
|
1031
|
+
id: 'id2',
|
|
1032
|
+
completed: false,
|
|
1033
|
+
title: 'todo 2',
|
|
1034
|
+
},
|
|
845
1035
|
},
|
|
846
|
-
}
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
describe('Initialization', () => {
|
|
1039
|
+
it('if not initial todo => load list', () => {
|
|
1040
|
+
store
|
|
1041
|
+
.setPartialState({
|
|
1042
|
+
todoIds: [],
|
|
1043
|
+
todoMap: {},
|
|
1044
|
+
})
|
|
1045
|
+
.onInit({ initialTodos: null })
|
|
1046
|
+
.test(({ state, actions }) => {
|
|
1047
|
+
// test state after initialization, ie:
|
|
1048
|
+
// - options.onPropsChange with onMount: true
|
|
1049
|
+
// - options.onMount
|
|
1050
|
+
expect(state.todoIds).toEqual([]);
|
|
1051
|
+
expect(state.todoMap).toEqual({});
|
|
1052
|
+
|
|
1053
|
+
// test derived state
|
|
1054
|
+
expect(state.todos).toEqual([]);
|
|
1055
|
+
|
|
1056
|
+
// test side effects also
|
|
1057
|
+
expect(actions.loadRemoteList).toHaveBeenCalled();
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it('initial todos => setup state and do not load list', () => {
|
|
1062
|
+
store
|
|
1063
|
+
.setPartialState({
|
|
1064
|
+
todoIds: [],
|
|
1065
|
+
todoMap: {},
|
|
1066
|
+
})
|
|
1067
|
+
.onInit({
|
|
1068
|
+
initialTodos: [
|
|
1069
|
+
{
|
|
1070
|
+
id: 'item1',
|
|
1071
|
+
title: 'Item 1',
|
|
1072
|
+
completed: false,
|
|
1073
|
+
},
|
|
1074
|
+
],
|
|
1075
|
+
})
|
|
1076
|
+
.test(({ state, actions }) => {
|
|
1077
|
+
// test state after initialization, ie:
|
|
1078
|
+
// - options.onPropsChange with onMount: true
|
|
1079
|
+
// - options.onMount
|
|
1080
|
+
expect(state.todoIds).toEqual(['item1']);
|
|
1081
|
+
expect(state.todoMap).toEqual({
|
|
1082
|
+
item1: {
|
|
1083
|
+
id: 'item1',
|
|
1084
|
+
title: 'Item 1',
|
|
1085
|
+
completed: false,
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// test derived state
|
|
1090
|
+
expect(state.todos).toEqual([
|
|
1091
|
+
{
|
|
1092
|
+
id: 'item1',
|
|
1093
|
+
title: 'Item 1',
|
|
1094
|
+
completed: false,
|
|
1095
|
+
},
|
|
1096
|
+
]);
|
|
1097
|
+
|
|
1098
|
+
// test side effects also
|
|
1099
|
+
expect(actions.loadRemoteList).not.toHaveBeenCalled();
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it('onSetNewTitle', async () => {
|
|
1105
|
+
// always return a promise whatever if the action is asynchronous or not
|
|
1106
|
+
const { state, prevState } = await store.getAction('onSetNewTitle').call('new Title');
|
|
1107
|
+
|
|
1108
|
+
// previous state is available for comparison
|
|
1109
|
+
expect(prevState.newTitle).toEqual('');
|
|
1110
|
+
expect(state.newTitle).toEqual('new Title');
|
|
847
1111
|
});
|
|
848
1112
|
|
|
849
1113
|
it('onCreate', async () => {
|
|
850
|
-
|
|
1114
|
+
const {
|
|
1115
|
+
state: { todoIds, todoMap, todos },
|
|
1116
|
+
} = await store
|
|
851
1117
|
// set partial state and return new store, source store is untouched
|
|
852
1118
|
.setPartialState({
|
|
853
1119
|
newTitle: 'new todo',
|
|
@@ -855,37 +1121,69 @@ describe('Todo', () => {
|
|
|
855
1121
|
// get a mock action that can be shared too !
|
|
856
1122
|
.getAction('onCreate')
|
|
857
1123
|
// Invokes action. It has no side effect on state of mock action
|
|
858
|
-
.call()
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1124
|
+
.call();
|
|
1125
|
+
|
|
1126
|
+
// assert state
|
|
1127
|
+
expect(todoIds).toEqual(['id', 'id2', 'id3']);
|
|
1128
|
+
expect(todoMap).toEqual({
|
|
1129
|
+
id: {
|
|
1130
|
+
id: 'id',
|
|
1131
|
+
completed: true,
|
|
1132
|
+
title: 'todo',
|
|
1133
|
+
},
|
|
1134
|
+
id2: {
|
|
1135
|
+
id: 'id2',
|
|
1136
|
+
completed: false,
|
|
1137
|
+
title: 'todo 2',
|
|
1138
|
+
},
|
|
1139
|
+
id3: {
|
|
1140
|
+
id: 'id3',
|
|
1141
|
+
completed: false,
|
|
1142
|
+
title: 'new todo',
|
|
1143
|
+
},
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
// assert derived state
|
|
1147
|
+
//
|
|
1148
|
+
// export const todoOptions: StoreOptions<State, Actions, Props, DerivedState> = {
|
|
1149
|
+
// derivedState: {
|
|
1150
|
+
// todos: {
|
|
1151
|
+
// getDeps: ({ s }) => [s.todoIds, s.todoMap],
|
|
1152
|
+
// get: ({ s }) => s.todoIds.map((id) => s.todoMap[id]),
|
|
1153
|
+
// },
|
|
1154
|
+
// },
|
|
1155
|
+
// };
|
|
1156
|
+
|
|
1157
|
+
expect(todos).toEqual([
|
|
1158
|
+
{
|
|
1159
|
+
id: 'id',
|
|
1160
|
+
completed: true,
|
|
1161
|
+
title: 'todo',
|
|
1162
|
+
},
|
|
1163
|
+
{
|
|
1164
|
+
id: 'id2',
|
|
1165
|
+
completed: false,
|
|
1166
|
+
title: 'todo 2',
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
id: 'id3',
|
|
1170
|
+
completed: false,
|
|
1171
|
+
title: 'new todo',
|
|
1172
|
+
},
|
|
1173
|
+
]);
|
|
881
1174
|
});
|
|
882
1175
|
|
|
883
|
-
|
|
884
|
-
// this mock action is shared
|
|
1176
|
+
describe('onToggle', () => {
|
|
1177
|
+
// this mock action is shared in two tests but they can run in parallel
|
|
885
1178
|
const onToggle = store.getAction('onToggle');
|
|
886
1179
|
|
|
887
|
-
|
|
888
|
-
|
|
1180
|
+
it('toggle false => true', async () => {
|
|
1181
|
+
// arguments of call() depend on the Store action signature, use auto completion!
|
|
1182
|
+
const {
|
|
1183
|
+
state: { todoIds, todoMap },
|
|
1184
|
+
actions,
|
|
1185
|
+
} = await onToggle.call('id2');
|
|
1186
|
+
|
|
889
1187
|
expect(todoIds).toEqual(['id', 'id2']);
|
|
890
1188
|
expect(todoMap).toEqual({
|
|
891
1189
|
id: {
|
|
@@ -899,9 +1197,18 @@ describe('Todo', () => {
|
|
|
899
1197
|
title: 'todo 2',
|
|
900
1198
|
},
|
|
901
1199
|
});
|
|
1200
|
+
|
|
1201
|
+
// if there are some side effects calling store actions, you can test them
|
|
1202
|
+
// as mock actions are injected instead of real actions.
|
|
1203
|
+
expect(actions.updateRemoteList).toHaveBeenCalled();
|
|
902
1204
|
});
|
|
903
1205
|
|
|
904
|
-
|
|
1206
|
+
it('toggle true => false', async () => {
|
|
1207
|
+
const {
|
|
1208
|
+
state: { todoIds, todoMap },
|
|
1209
|
+
actions,
|
|
1210
|
+
} = await onToggle.call('id');
|
|
1211
|
+
|
|
905
1212
|
expect(todoIds).toEqual(['id', 'id2']);
|
|
906
1213
|
expect(todoMap).toEqual({
|
|
907
1214
|
id: {
|
|
@@ -915,53 +1222,103 @@ describe('Todo', () => {
|
|
|
915
1222
|
title: 'todo 2',
|
|
916
1223
|
},
|
|
917
1224
|
});
|
|
1225
|
+
|
|
1226
|
+
// if there are some side effects calling store actions, you can test them
|
|
1227
|
+
// as mock actions are injected instead of real actions.
|
|
1228
|
+
expect(actions.updateRemoteList).toHaveBeenCalled();
|
|
918
1229
|
});
|
|
919
1230
|
});
|
|
920
1231
|
|
|
921
1232
|
// Another store / action
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1233
|
+
describe('loadRemoteList', () => {
|
|
1234
|
+
// action is shared
|
|
1235
|
+
const action = store.getAction('loadRemoteList');
|
|
1236
|
+
|
|
1237
|
+
it('success', async () => {
|
|
1238
|
+
const { state } = await action
|
|
1239
|
+
// override default promise result
|
|
1240
|
+
.promiseResolves([
|
|
1241
|
+
{
|
|
1242
|
+
id: 'item1',
|
|
1243
|
+
title: 'item title',
|
|
1244
|
+
completed: true,
|
|
1245
|
+
},
|
|
1246
|
+
])
|
|
1247
|
+
.call();
|
|
1248
|
+
|
|
1249
|
+
expect(state.error).toBeFalsy();
|
|
1250
|
+
expect(state.todoIds).toEqual(['item1']);
|
|
1251
|
+
expect(state.todoMap).toEqual({
|
|
1252
|
+
item1: {
|
|
1253
|
+
id: 'item1',
|
|
1254
|
+
title: 'item title',
|
|
1255
|
+
completed: true,
|
|
1256
|
+
},
|
|
937
1257
|
});
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
it('error', async () => {
|
|
1261
|
+
const { state } = await action
|
|
1262
|
+
// override default promise result with error
|
|
1263
|
+
.promiseRejects(new Error('boom'))
|
|
1264
|
+
.call();
|
|
1265
|
+
|
|
1266
|
+
expect(state.error).toBeTruthy();
|
|
1267
|
+
});
|
|
938
1268
|
});
|
|
939
1269
|
|
|
940
|
-
//
|
|
1270
|
+
// Test effects and side effect when props are changing
|
|
1271
|
+
//
|
|
1272
|
+
// Here:
|
|
1273
|
+
// export const todoOptions: StoreOptions<State, Actions, Props, DerivedState> = {
|
|
1274
|
+
// onPropsChange: [
|
|
1275
|
+
// {
|
|
1276
|
+
// getDeps: (p) => [p.initialTodos],
|
|
1277
|
+
// effects: ({ s, p }) => ({ ...s, ...splitList(p.initialTodos) }),
|
|
1278
|
+
// onMount: true,
|
|
1279
|
+
// },
|
|
1280
|
+
// ],
|
|
1281
|
+
// };
|
|
941
1282
|
it('onPropsChange', () => {
|
|
942
1283
|
store
|
|
943
1284
|
// onPropsChange allows to test prop change, no side effect on original store
|
|
944
1285
|
// to create a new store with new props, use setProps or setPartialProps
|
|
945
1286
|
.onPropsChange({
|
|
946
|
-
|
|
1287
|
+
initialTodos: [
|
|
1288
|
+
{
|
|
1289
|
+
id: 'item1',
|
|
1290
|
+
title: 'Item 1',
|
|
1291
|
+
completed: false,
|
|
1292
|
+
},
|
|
1293
|
+
],
|
|
947
1294
|
})
|
|
948
|
-
.test((
|
|
949
|
-
// test
|
|
950
|
-
expect(
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1295
|
+
.test(({ state, actions }) => {
|
|
1296
|
+
// test state after prop has changed
|
|
1297
|
+
expect(state.todoIds).toEqual(['item1']);
|
|
1298
|
+
expect(state.todoMap).toEqual({
|
|
1299
|
+
item1: {
|
|
1300
|
+
id: 'item1',
|
|
1301
|
+
title: 'Item 1',
|
|
1302
|
+
completed: false,
|
|
1303
|
+
},
|
|
957
1304
|
});
|
|
1305
|
+
|
|
1306
|
+
// test derived state after prop has changed
|
|
1307
|
+
expect(state.todos).toEqual([
|
|
1308
|
+
{
|
|
1309
|
+
id: 'item1',
|
|
1310
|
+
title: 'Item 1',
|
|
1311
|
+
completed: false,
|
|
1312
|
+
},
|
|
1313
|
+
]);
|
|
1314
|
+
|
|
1315
|
+
// test side effects also
|
|
1316
|
+
expect(actions.loadRemoteList).not.toHaveBeenCalled();
|
|
958
1317
|
});
|
|
959
1318
|
});
|
|
960
1319
|
});
|
|
961
1320
|
```
|
|
962
1321
|
|
|
963
|
-
[](https://codesandbox.io/s/todo-forked-ml0z6?file=/src/TodoApp.tsx)
|
|
964
|
-
|
|
965
1322
|
## Error testing
|
|
966
1323
|
|
|
967
1324
|
### Managed error
|
|
@@ -1083,10 +1440,9 @@ TypeError: Cannot set property 'crash' of null
|
|
|
1083
1440
|
### MockStore
|
|
1084
1441
|
|
|
1085
1442
|
| Function | Arguments | Returns | Description |
|
|
1086
|
-
| --------------- | ------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------- |
|
|
1443
|
+
| --------------- | ------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------- | --- |
|
|
1087
1444
|
| getAction | actionName: keyof A | MockStoreAction | Get a mock action instance |
|
|
1088
|
-
|
|
|
1089
|
-
| onMount | props: P | MockResult | Test onMount. Does not change internal props. |
|
|
1445
|
+
| onInit | props?: Partial<P> | MockResult | Test store initialization (onPropsChanged marked onMount + onMount) | |
|
|
1090
1446
|
| onPropsChange | props: P, init?: boolean | MockResult | Test prop changes. Does not change internal props. Init allows to test onMount prop changes |
|
|
1091
1447
|
| setPartialProps | props: Partial<P> | new mock store | Create a new store with the merge of current props and partial props |
|
|
1092
1448
|
| setPartialState | state: Partial<S> | new mock store | Create a new store with the merge of current state and partial state |
|