Last active
September 5, 2020 12:36
-
-
Save sauldeleon/b4b485ff96b62ac215a1f3a55749293f to your computer and use it in GitHub Desktop.
Storybook meets axios meets redux meets thunk
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
This is my workarround to create a React Story for a component which internally dispatch multiple thunk actions | |
and also retrieve info from some server. This works also on making an story in which its main component wraps another redux | |
connected components. | |
*/ | |
/** | |
BEHAVIOUR EXPLANATION | |
This component depends on an ID (fetchingDataId) which is stored in the parent component which contains many SomeComponents, so I need to mock the store before SomeComponent's story loads | |
Then, when a single SomeComponent loads, it renders two connected subcomponents which I dont have control from Storybook. And I want the story to render both components at the same time. | |
FooComponent, which API call is | |
- fooRequest?param1=1111¶m2=bbbb | |
BarComponent, which API call is | |
- barRequest?param1=1111¶m2=bbbb | |
So: | |
ParentComponent: (fetchingDataId=anotherProp='aaaa') [ | |
- SomeComponent id=aProp='1234'(Story component) | |
* FooComponent (connected subcomponent) | |
* BarComponent (connected subcomponent) | |
- SomeComponent id=aProp='5678'(Story component) | |
* FooComponent (connected subcomponent) | |
* BarComponent (connected subcomponent) | |
- more SomeComponents... | |
] | |
*/ | |
/** | |
--------------------------------------------------------------------- | |
STORYBOOK CONFIGURATION | |
--------------------------------------------------------------------- | |
*/ | |
/** | |
SomeComponent.stories.js | |
My Storybook component definition | |
*/ | |
import React from 'react' | |
import { storiesOf } from '@storybook/react' | |
import { withInfo } from '@storybook/addon-info' | |
import Provider from 'storybook/Provider' | |
import ParentComponent from './ParentComponent' //import of connected with Redux component | |
import SomeComponent from './SomeComponent' //import of connected with Redux component | |
const customActions = [ | |
{ | |
type: 'FOO_BAR_REQUEST', | |
payload: { fetchingDataId: 'aaaa' }, | |
}, | |
] | |
storiesOf('SomeComponent', module) | |
.addDecorator(story => <Provider customActions={customActions} story={story()} /> ) | |
.add('default', () => <SomeComponent aProp="1234" anotherProp="aaaa" /> ) | |
.add('parent', () => <ParentComponent aProp="1234" anotherProp="aaaa" /> ) | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** Custom Provider for Storybook */ | |
import React from 'react' | |
import { Provider as ReduxProvider } from 'react-redux' | |
import { createStore, applyMiddleware } from 'redux' | |
import reducer from 'reducers' //your combined reducers | |
import thunk from 'redux-thunk' | |
const store = createStore(reducer, applyMiddleware(thunk)) | |
/** | |
Provider.js | |
Allow custom Storybook Provider to pass a created store or dispatch some actions before displaying the Story | |
*/ | |
export default function Provider({ story, customStore, customActions }) { | |
const finalStore = customStore ? customStore : store | |
customActions && | |
customActions.forEach(action => { | |
finalStore.dispatch(action) | |
}) | |
return <ReduxProvider store={finalStore}>{story}</ReduxProvider> | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
MyApi.js | |
In order to allow the component to make API calls, I used axios | |
*/ | |
import axios from 'axios' | |
const MyApi = axios.create({ | |
//use of STORYBOOK_* environment variables | |
baseURL: process.env.STORYBOOK_MODE === 'enabled' ? '' : '/api/custom/url/', | |
}) | |
//dynamic export | |
export default new Promise(async $export => { | |
if (process.env.STORYBOOK_MODE === 'enabled') { | |
//dynamic import. This way, mock library wont come on final build | |
await import('storybook/mock').then(mock => { | |
mock.configureMock(MyApi) | |
}) | |
} else { | |
MyApi.interceptors.request.use( | |
config => { | |
//...some configuration | |
return config | |
}, | |
err => { | |
return Promise.reject(err) | |
} | |
) | |
MyApi.interceptors.response.use(null, err => { | |
// ... some configuration | |
return Promise.reject(err) | |
}) | |
} | |
$export(MyApi) | |
}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
storybook/mock/index.js file | |
Here I will use axios-mock-adapter to mock all custom requests | |
*/ | |
import MockAdapter from 'axios-mock-adapter' | |
const FOO_RESPONSE = require('storybook/mock/FOO_RESPONSE.json') | |
const BAR_RESPONSE = require('storybook/mock/BAR_RESPONSE.json') | |
// ... more mocked responses | |
const mocks = { | |
FOO_RESPONSE, | |
BAR_RESPONSE, | |
// ...more mocked responses | |
} | |
export const configureMock = api => { | |
const mock = new MockAdapter(api, { delayResponse: 1500 }) | |
mock | |
.onGet(/.*fooRequest.*/) | |
.reply(200, mocks['FOO_RESPONSE']) | |
.onGet(/.*barRequest.*/) | |
.reply(200, mocks['BAR_RESPONSE']) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
my-custom-action-reducer.js | |
Finally, my action and my reducer with thunk applied on the App Provider config | |
*/ | |
import MyApi from 'MyApi' | |
/** | |
Both API calls needs to resolve a promise first, as the MyApi dynamic export returns a promise. This promise, depending on the environment | |
will contain the original production API or the mocked API. | |
*/ | |
const getFooData = (aProp, anotherProp) => { | |
return MyApi.then(api => | |
api({ | |
method: 'GET', | |
url: `fooRequest?param1=${aProp}¶m2=${anotherProp}`, | |
}) | |
) | |
} | |
const getBarData = (aProp, anotherProp) => { | |
return MyApi.then(api => | |
api({ | |
method: 'GET', | |
url: `barRequest?param1=${aProp}¶m2=${anotherProp}`, | |
}) | |
) | |
} | |
// Actions | |
export const loadFooData = (aProp, anotherProp) => { | |
return dispatch => { | |
dispatch({ type: 'FOO_DATA_REQUEST' }) | |
return getFooData(aProp, anotherProp) | |
.then(response => { | |
dispatch({ | |
type: 'FOO_DATA_LOADED', | |
payload: { | |
aProp, | |
anotherProp, | |
data: response, | |
}, | |
}) | |
}) | |
.catch(err => { | |
dispatch({ type: 'FOO_DATA_ERROR', error: err }) | |
}) | |
} | |
} | |
export const loadBarData = (aProp, anotherProp) => { | |
return dispatch => { | |
dispatch({ type: 'BAR_DATA_REQUEST' }) | |
return getBarData(aProp, anotherProp) | |
.then(response => { | |
dispatch({ | |
type: 'BAR_DATA_LOADED', | |
payload: { | |
aProp, | |
anotherProp, | |
data: response, | |
}, | |
}) | |
}) | |
.catch(err => { | |
dispatch({ type: 'BAR_DATA_ERROR', error: err }) | |
}) | |
} | |
} | |
const defaultState = { | |
error: null, | |
fetchingDataId: null, | |
fooData: {}, | |
barData: {}, | |
isFetchingFooData: false, | |
isFetchingBarData: false, | |
} | |
// reducers | |
/** | |
In order to understand reducer's behaviour, this is the state after some successfull calls in this example | |
{ | |
error: null, | |
fetchingDataId: null, | |
fooData: { | |
'1234/aaaa': { ...serverFooData }, | |
'5678/aaaa': { ...serverFooData } | |
}, | |
barData: { | |
'1234/aaaa': { ...serverBarData }, | |
'5678/aaaa': { ...serverBarData } | |
}, | |
isFetchingFooData: false, | |
isFetchingBarData: false, | |
} | |
*/ | |
const fooBarReducers = (state = defaultState, action) => { | |
switch (action.type) { | |
case 'FOO_BAR_REQUEST': | |
return { | |
...state, | |
fetchingDataId: action.payload.fetchingDataId, | |
} | |
case 'FOO_DATA_REQUEST': | |
return { | |
...state, | |
isFetchingFooData: true, | |
} | |
case 'FOO_DATA_LOADED': | |
state.fooData[ | |
action.payload.aProp + '/' + action.payload.anotherProp | |
] = | |
action.payload.data | |
return { | |
...state, | |
isFetchingFooData: false, | |
} | |
case 'FOO_DATA_ERROR': | |
state.fooData[ | |
action.payload.aProp + '/' + action.payload.anotherProp | |
] = null | |
return { | |
...state, | |
isFetchingFooData: false, | |
error: action.error, | |
} | |
case 'ORDER_BAR_DATA_REQUEST': | |
return { | |
...state, | |
isFetchingBarData: true, | |
} | |
case 'ORDER_BAR_DATA_LOADED': | |
state.barData[ | |
action.payload.aProp + '/' + action.payload.anotherProp | |
] = | |
action.payload.data | |
return { | |
...state, | |
isFetchingBarData: false, | |
} | |
case 'BAR_DATA_ERROR': | |
state.barData[ | |
action.payload.aProp + '/' + action.payload.anotherProp | |
] = null | |
return { | |
...state, | |
isFetchingBarData: false, | |
error: action.error, | |
} | |
default: | |
return state | |
} | |
} | |
export default fooBarReducers | |
/** | |
Sources: | |
https://medium.com/@rafaelrozon/mock-axios-storybook-72404b1d427b | |
https://medium.com/@WebReflection/javascript-dynamic-import-export-b0e8775a59d4 | |
*/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment