├── .prettierrc ├── .prettierignore ├── docs ├── addon-redux.png ├── addon-redux-header.png ├── v1 │ ├── addon-redux-state-panel.png │ ├── addon-redux-history-panel.png │ └── README.md ├── v2 │ ├── addon-redux-state-panel.png │ └── addon-redux-history-panel.png ├── addon-redux.svg └── addon-redux-header.svg ├── .gitignore ├── .storybook ├── preview.js └── main.js ├── preset.js ├── .babelrc.js ├── src ├── util │ ├── get.ts │ ├── useStoryChanged.ts │ ├── jsonHelper.ts │ ├── set.test.ts │ ├── get.test.ts │ ├── useSetStateFromParameter.ts │ ├── useArgsSyncMapReduxSet.ts │ ├── useArgsSyncMapReduxPath.ts │ ├── useSyncReduxArgs.ts │ └── set.ts ├── index.ts ├── preset │ ├── preview.ts │ └── manager.tsx ├── redux │ ├── actionCreators.ts │ ├── withRedux.tsx │ └── enhancer.ts ├── constants.ts ├── typings.d.ts └── components │ ├── ObjectEditor.tsx │ ├── StateView.tsx │ └── HistoryView.tsx ├── stories ├── Header.stories.js ├── Text.stories.js ├── Text.js ├── Page.stories.js ├── header.css ├── store │ ├── reducer.js │ └── index.js ├── button.css ├── SharedState.js ├── SharedState.stories.js ├── page.css ├── Header.js ├── Button.js ├── Button.stories.js └── Page.js ├── tsconfig.json ├── scripts ├── eject-typescript.mjs └── prepublish-checks.mjs ├── LICENSE ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /docs/addon-redux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frodare/addon-redux/HEAD/docs/addon-redux.png -------------------------------------------------------------------------------- /docs/addon-redux-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frodare/addon-redux/HEAD/docs/addon-redux-header.png -------------------------------------------------------------------------------- /docs/v1/addon-redux-state-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frodare/addon-redux/HEAD/docs/v1/addon-redux-state-panel.png -------------------------------------------------------------------------------- /docs/v2/addon-redux-state-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frodare/addon-redux/HEAD/docs/v2/addon-redux-state-panel.png -------------------------------------------------------------------------------- /docs/v1/addon-redux-history-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frodare/addon-redux/HEAD/docs/v1/addon-redux-history-panel.png -------------------------------------------------------------------------------- /docs/v2/addon-redux-history-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frodare/addon-redux/HEAD/docs/v2/addon-redux-history-panel.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | storybook-static/ 4 | build-storybook.log 5 | .DS_Store 6 | .env 7 | .history 8 | yarn-error.log 9 | .vscode -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | decorators: [], 4 | loaders: [ 5 | async () => ({ 6 | store: await import('../stories/store') 7 | }) 8 | ] 9 | }; 10 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: [ 3 | "../stories/**/*.stories.mdx", 4 | "../stories/**/*.stories.@(js|jsx|ts|tsx)", 5 | ], 6 | addons: ["../preset.js", "@storybook/addon-essentials"], 7 | }; 8 | -------------------------------------------------------------------------------- /preset.js: -------------------------------------------------------------------------------- 1 | function config (entry = []) { 2 | return [...entry, require.resolve('./dist/esm/preset/preview')] 3 | } 4 | 5 | function managerEntries (entry = []) { 6 | return [...entry, require.resolve('./dist/esm/preset/manager')] 7 | } 8 | 9 | module.exports = { 10 | managerEntries, 11 | config 12 | } 13 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript", 5 | "@babel/preset-react", 6 | ], 7 | env: { 8 | esm: { 9 | presets: [ 10 | [ 11 | "@babel/preset-env", 12 | { 13 | modules: false, 14 | }, 15 | ], 16 | ], 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/util/get.ts: -------------------------------------------------------------------------------- 1 | 2 | const PATH_SEPARATOR = /[,[\].]+?/ 3 | 4 | const reducePath = (res: any, key: string): any => res?.[key] 5 | 6 | const get = (obj: any, path: string, defaultValue: any = undefined): any => { 7 | const result = path.split(PATH_SEPARATOR).reduce(reducePath, obj) 8 | return (result === undefined || result === obj) ? defaultValue : result 9 | } 10 | 11 | export default get 12 | -------------------------------------------------------------------------------- /stories/Header.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Header } from './Header' 4 | 5 | export default { 6 | title: 'Example/Header', 7 | component: Header 8 | } 9 | 10 | const Template = (args) =>
11 | 12 | export const LoggedIn = Template.bind({}) 13 | LoggedIn.args = { 14 | user: {} 15 | } 16 | 17 | export const LoggedOut = Template.bind({}) 18 | LoggedOut.args = {} 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | if ((module?.hot?.decline) != null) { 2 | module.hot.decline() 3 | } 4 | 5 | export { default as enhancer } from './redux/enhancer' 6 | export { default as withRedux } from './redux/withRedux' 7 | export { PARAM_REDUX_MERGE_STATE, ARG_REDUX_PATH, ARG_REDUX_SET_STATE, ACTIONS_TYPES } from './constants' 8 | export { default as set } from './util/set' 9 | export { default as get } from './util/get' 10 | 11 | export default {} 12 | -------------------------------------------------------------------------------- /stories/Text.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Text from './Text' 3 | import { PARAM_REDUX_MERGE_STATE } from '../src/constants' 4 | 5 | export default { 6 | title: 'Example/Text', 7 | component: Text, 8 | parameters: { 9 | [PARAM_REDUX_MERGE_STATE]: '{"asdf": "asdf"}' 10 | } 11 | } 12 | 13 | const Template = (args) => 14 | 15 | export const Primary = Template.bind({}) 16 | Primary.args = {} 17 | -------------------------------------------------------------------------------- /stories/Text.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import { useSelector, useDispatch } from 'react-redux' 4 | 5 | const Text = () => { 6 | const dispatch = useDispatch() 7 | 8 | const value = useSelector(state => state.text) || '' 9 | 10 | const onChange = ev => dispatch({ 11 | type: 'setText', 12 | value: ev.target.value 13 | }) 14 | 15 | return 16 | } 17 | 18 | export default Text 19 | -------------------------------------------------------------------------------- /stories/Page.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Page } from './Page' 4 | import * as HeaderStories from './Header.stories' 5 | 6 | export default { 7 | title: 'Example/Page', 8 | component: Page 9 | } 10 | 11 | const Template = (args) => 12 | 13 | export const LoggedIn = Template.bind({}) 14 | LoggedIn.args = { 15 | ...HeaderStories.LoggedIn.args 16 | } 17 | 18 | export const LoggedOut = Template.bind({}) 19 | LoggedOut.args = { 20 | ...HeaderStories.LoggedOut.args 21 | } 22 | -------------------------------------------------------------------------------- /src/util/useStoryChanged.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | import { useStorybookApi } from '@storybook/api' 3 | 4 | const s = (s: string | undefined): string => s === undefined ? '' : s 5 | 6 | const useStoryChanged = (): boolean => { 7 | const api = useStorybookApi() 8 | const storyId = s(api.getUrlState().storyId) 9 | const storyIdRef = useRef('') 10 | const storyChanged = storyId !== '' && storyIdRef.current !== storyId 11 | storyIdRef.current = storyId 12 | return storyChanged 13 | } 14 | 15 | export default useStoryChanged 16 | -------------------------------------------------------------------------------- /src/util/jsonHelper.ts: -------------------------------------------------------------------------------- 1 | 2 | export const parse = (json: string): any => { 3 | if (json === undefined) { 4 | return undefined 5 | } 6 | try { 7 | return JSON.parse(json ?? '{}') 8 | } catch (err) { 9 | return {} 10 | } 11 | } 12 | 13 | export const stringify = (obj: any): string | undefined => { 14 | if (obj === undefined) { 15 | return undefined 16 | } 17 | if (typeof obj === 'string') { 18 | obj = parse(obj) 19 | } 20 | try { 21 | return JSON.stringify(obj) 22 | } catch (err) { 23 | return '{}' 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/util/set.test.ts: -------------------------------------------------------------------------------- 1 | import set from './set' 2 | 3 | test('hit', () => { 4 | const start = { foo: [undefined, { bar: { baz: 'prev' } }] } 5 | const result = set(start, 'foo.1.bar.a', 'howdy') 6 | expect(result).toEqual({ foo: [undefined, { bar: { baz: 'prev', a: 'howdy' } }] }) 7 | }) 8 | 9 | test('do not mutate', () => { 10 | const input = { a: { b: 'c' } } 11 | const inputJson = JSON.stringify(input) 12 | const output = set(input, 'a.c', 'd') 13 | expect(JSON.stringify(input)).toEqual(inputJson) 14 | expect(output).toEqual({ a: { b: 'c', c: 'd' } }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/preset/preview.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A decorator is a way to wrap a story in extra “rendering” functionality. Many addons define decorators 3 | * in order to augment stories: 4 | * - with extra rendering 5 | * - gather details about how a story is rendered 6 | * 7 | * When writing stories, decorators are typically used to wrap stories with extra markup or context mocking. 8 | * 9 | * https://storybook.js.org/docs/react/writing-stories/decorators#gatsby-focus-wrapper 10 | */ 11 | 12 | import withRedux from '../redux/withRedux' 13 | 14 | export const decorators = [withRedux()] 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "baseUrl": ".", 5 | "emitDecoratorMetadata": true, 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "incremental": false, 9 | "isolatedModules": true, 10 | "jsx": "react", 11 | "lib": ["es2017", "dom"], 12 | "module": "commonjs", 13 | "noImplicitAny": true, 14 | "rootDir": "./src", 15 | "skipLibCheck": true, 16 | "target": "es5", 17 | "strictNullChecks": true 18 | }, 19 | "include": [ 20 | "src/**/*" 21 | ], 22 | } -------------------------------------------------------------------------------- /stories/header.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 4 | padding: 15px 20px; 5 | display: flex; 6 | align-items: center; 7 | justify-content: space-between; 8 | } 9 | 10 | svg { 11 | display: inline-block; 12 | vertical-align: top; 13 | } 14 | 15 | h1 { 16 | font-weight: 900; 17 | font-size: 20px; 18 | line-height: 1; 19 | margin: 6px 0 6px 10px; 20 | display: inline-block; 21 | vertical-align: top; 22 | } 23 | 24 | button + button { 25 | margin-left: 10px; 26 | } 27 | -------------------------------------------------------------------------------- /src/util/get.test.ts: -------------------------------------------------------------------------------- 1 | import get from './get' 2 | 3 | test('hit', () => { 4 | expect(get({ foo: [{}, { bar: 'howdy' }] }, 'foo.1.bar')).toBe('howdy') 5 | }) 6 | 7 | test('miss', () => { 8 | expect(get({ foo: [{}, { bar: 'howdy' }] }, 'fooa.6.barz')).toBe(undefined) 9 | expect(get({}, 'fooa.6.barz')).toBe(undefined) 10 | expect(get(null, 'fooa.6.barz')).toBe(undefined) 11 | expect(get(undefined, 'fooa.6.barz')).toBe(undefined) 12 | expect(get(15, 'fooa.6.barz')).toBe(undefined) 13 | expect(get('15', 'fooa.6.barz')).toBe(undefined) 14 | }) 15 | 16 | test('default', () => { 17 | expect(get({ foo: [{}, { bar: 'howdy' }] }, 'fooa.6.barz', 'hi')).toBe('hi') 18 | }) 19 | -------------------------------------------------------------------------------- /stories/store/reducer.js: -------------------------------------------------------------------------------- 1 | 2 | const initalState = { 3 | counters: [{ 4 | name: 'test', 5 | count: 0 6 | }], 7 | text: '2021-01-04T17:49:03.343Z' 8 | } 9 | 10 | const reducer = (state = initalState, action) => { 11 | if (action.type === 'increment') { 12 | const counters = [...state.counters] 13 | counters[action.id] = { ...counters[action.id], count: counters[action.id].count + 1 } 14 | return { 15 | ...state, 16 | counters 17 | } 18 | } 19 | 20 | if (action.type === 'setText') { 21 | return { 22 | ...state, 23 | text: action.value 24 | } 25 | } 26 | 27 | return state 28 | } 29 | 30 | export default reducer 31 | -------------------------------------------------------------------------------- /stories/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, compose, applyMiddleware } from 'redux' 2 | import reducer from './reducer' 3 | import { enhancer as withReduxEnhancer } from '../../dist/esm' 4 | 5 | const createMiddlewareEnhancer = () => { 6 | const middleware = [] 7 | return applyMiddleware(...middleware) 8 | } 9 | 10 | const createEnhancer = () => { 11 | const enhancers = [] 12 | enhancers.push(createMiddlewareEnhancer()) 13 | if (process.env.NODE_ENV !== 'production') { 14 | enhancers.push(withReduxEnhancer) 15 | } 16 | return compose(...enhancers) 17 | } 18 | 19 | const store = createStore(reducer, createEnhancer()) 20 | 21 | window.top.store = store 22 | 23 | export default store 24 | -------------------------------------------------------------------------------- /src/redux/actionCreators.ts: -------------------------------------------------------------------------------- 1 | import { ActionCreator } from 'redux' 2 | 3 | import { ACTIONS_TYPES } from '../constants' 4 | import { State } from '../typings' 5 | 6 | export const resetStateAction: ActionCreator = () => ({ 7 | type: ACTIONS_TYPES.RESET_REDUX_TYPE 8 | }) 9 | 10 | export const mergeStateAction: ActionCreator = (state = {}) => ({ 11 | type: ACTIONS_TYPES.MERGE_STATE_TYPE, 12 | state 13 | }) 14 | 15 | export const setStateAction: ActionCreator = state => ({ 16 | type: ACTIONS_TYPES.SET_STATE_TYPE, 17 | state 18 | }) 19 | 20 | export const setStateAtPathAction: ActionCreator = (path: string, value: any) => ({ 21 | type: ACTIONS_TYPES.SET_STATE_AT_PATH_TYPE, 22 | path, 23 | value 24 | }) 25 | -------------------------------------------------------------------------------- /stories/button.css: -------------------------------------------------------------------------------- 1 | .storybook-button { 2 | font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; 3 | font-weight: 700; 4 | border: 0; 5 | border-radius: 3em; 6 | cursor: pointer; 7 | display: inline-block; 8 | line-height: 1; 9 | } 10 | .storybook-button--primary { 11 | color: white; 12 | background-color: #1ea7fd; 13 | } 14 | .storybook-button--secondary { 15 | color: #333; 16 | background-color: transparent; 17 | box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; 18 | } 19 | .storybook-button--small { 20 | font-size: 12px; 21 | padding: 10px 16px; 22 | } 23 | .storybook-button--medium { 24 | font-size: 14px; 25 | padding: 11px 20px; 26 | } 27 | .storybook-button--large { 28 | font-size: 16px; 29 | padding: 12px 24px; 30 | } 31 | -------------------------------------------------------------------------------- /src/util/useSetStateFromParameter.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react' 2 | import { stringify } from '../util/jsonHelper' 3 | import { EVENTS, PARAM_REDUX_MERGE_STATE } from '../constants' 4 | import { useChannel, useParameter } from '@storybook/api' 5 | import useStoryChanged from '../util/useStoryChanged' 6 | 7 | const useSetStateFromParameter = (): void => { 8 | const emit = useChannel({}) 9 | const mergeStateRef = useRef('') 10 | const mergeState = useParameter(PARAM_REDUX_MERGE_STATE, '') 11 | const storyChanged = useStoryChanged() 12 | 13 | useEffect(() => { 14 | const mergeStateChanged = mergeState !== mergeStateRef.current 15 | 16 | mergeStateRef.current = mergeState 17 | 18 | if (mergeState !== '' && (storyChanged || mergeStateChanged)) { 19 | emit(EVENTS.MERGE_STATE, stringify(mergeState)) 20 | } 21 | }, [mergeState, storyChanged]) 22 | } 23 | 24 | export default useSetStateFromParameter 25 | -------------------------------------------------------------------------------- /src/util/useArgsSyncMapReduxSet.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | import { useArgTypes, ArgTypes, ArgType } from '@storybook/api' 3 | 4 | import { ARG_REDUX_SET_STATE } from '../constants' 5 | import { ArgSyncSetEntry } from '../typings' 6 | 7 | const syncEnabled = ([name, data]: [string, ArgType]): boolean => data[ARG_REDUX_SET_STATE] 8 | 9 | const useSyncMap = (): ArgSyncSetEntry[] => { 10 | const types = useArgTypes() 11 | const syncMapRef = useRef([]) 12 | const typesRef = useRef() 13 | 14 | const typesChanged = typesRef.current !== types 15 | if (typesRef.current !== types) { 16 | typesRef.current = types 17 | } 18 | 19 | if (typesChanged) { 20 | syncMapRef.current = Object.entries(types) 21 | .filter(syncEnabled) 22 | .map(([name, data]) => ({ name, setter: data[ARG_REDUX_SET_STATE] })) // check if function 23 | } 24 | 25 | return syncMapRef.current 26 | } 27 | 28 | export default useSyncMap 29 | -------------------------------------------------------------------------------- /scripts/eject-typescript.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | 3 | // Copy TS files and delete src 4 | await $`cp -r ./src ./srcTS`; 5 | await $`rm -rf ./src`; 6 | await $`mkdir ./src`; 7 | 8 | // Convert TS code to JS 9 | await $`babel --no-babelrc --presets @babel/preset-typescript ./srcTS -d ./src --extensions \".js,.jsx,.ts,.tsx\" --ignore "./srcTS/typings.d.ts"`; 10 | 11 | // Format the newly created .js files 12 | await $`prettier --write ./src`; 13 | 14 | // Add in minimal files required for the TS build setup 15 | await $`touch ./src/dummy.ts`; 16 | await $`printf "export {};" >> ./src/dummy.ts`; 17 | 18 | await $`touch ./src/typings.d.ts`; 19 | await $`printf 'declare module "global";' >> ./src/typings.d.ts`; 20 | 21 | // Clean up 22 | await $`rm -rf ./srcTS`; 23 | 24 | console.log( 25 | chalk.green.bold` 26 | TypeScript Ejection complete!`, 27 | chalk.green` 28 | Addon code converted with JS. The TypeScript build setup is still available in case you want to adopt TypeScript in the future. 29 | ` 30 | ); 31 | -------------------------------------------------------------------------------- /stories/SharedState.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector, useDispatch } from 'react-redux' 3 | 4 | export const SharedStateManager = ({ showButton }) => { 5 | const counter = useSelector(state => state.counters[0].count) 6 | const dispatch = useDispatch() 7 | 8 | if (showButton) { 9 | return ( 10 | <> 11 | 18 |

19 | {counter} 20 | 21 | ); 22 | } else { 23 | return ( 24 | <> 25 | The state shown in this counter should be reset to its initial value 26 | Even if its value in the Redux state was incremented by the Writer story 27 |

28 | {counter} 29 | 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/util/useArgsSyncMapReduxPath.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | import { useArgTypes, ArgTypes, ArgType } from '@storybook/api' 3 | 4 | import { ARG_REDUX_PATH } from '../constants' 5 | import { ArgSyncPathEntry } from '../typings' 6 | 7 | const syncEnabled = ([name, data]: [string, ArgType]): boolean => data[ARG_REDUX_PATH] 8 | 9 | const toString = (o: any): string => o == null ? '' : o.toString() 10 | 11 | const useSyncMap = (): ArgSyncPathEntry[] => { 12 | const types = useArgTypes() 13 | const syncMapRef = useRef([]) 14 | const typesRef = useRef() 15 | 16 | const typesChanged = typesRef.current !== types 17 | if (typesRef.current !== types) { 18 | typesRef.current = types 19 | } 20 | 21 | if (typesChanged) { 22 | syncMapRef.current = Object.entries(types) 23 | .filter(syncEnabled) 24 | .map(([name, data]) => ({ name, path: toString(data[ARG_REDUX_PATH]) })) 25 | } 26 | 27 | return syncMapRef.current 28 | } 29 | 30 | export default useSyncMap 31 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ADDON_ID = 'storybook/addon-redux' 2 | 3 | export const STATE_ID_HISTORY = `${ADDON_ID}/useState/history` 4 | export const STATE_ID_STORE = `${ADDON_ID}/useState/store` 5 | 6 | export const PANEL_ID_HISTORY = `${ADDON_ID}/panel/history` 7 | export const PANEL_ID_STORE = `${ADDON_ID}/panel/store` 8 | 9 | export const PARAM_REDUX_MERGE_STATE = 'PARAM_REDUX_MERGE_STATE' 10 | export const ARG_REDUX_PATH = 'ARG_REDUX_PATH' 11 | export const ARG_REDUX_SET_STATE = 'ARG_REDUX_SET_STATE' 12 | 13 | export const ACTIONS_TYPES = { 14 | RESET_REDUX_TYPE: '@@WITH_RESET_REDUX', 15 | MERGE_STATE_TYPE: '@@WITH_REDUX_MERGE_STATE', 16 | SET_STATE_TYPE: '@@WITH_REDUX_SET_STATE', 17 | SET_STATE_AT_PATH_TYPE: '@@SET_STATE_AT_PATH_TYPE' 18 | } 19 | 20 | export const EVENTS = { 21 | INIT: `${ADDON_ID}/init`, 22 | ON_DISPATCH: `${ADDON_ID}/on_dispatch`, 23 | SET_STATE: `${ADDON_ID}/set_state`, 24 | SET_STATE_AT_PATH: `${ADDON_ID}/set_state_at_path`, 25 | MERGE_STATE: `${ADDON_ID}/merge_state`, 26 | DISPATCH: `${ADDON_ID}/dispatch` 27 | } 28 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | import { Args } from '@storybook/api' 2 | import { AnyAction, Store } from 'redux' 3 | 4 | declare module 'global' 5 | 6 | type State = any 7 | 8 | type Enhancer = (a: T) => T 9 | 10 | type Dispatcher = (action: AnyAction) => void 11 | 12 | type PanelComponent = (props: AddonPanelProps) => JSX.Element 13 | 14 | type StoreListener = null | ((action: AnyAction, prev: State, next: State) => void) 15 | 16 | interface AddonReduxEnabled { 17 | listenToStateChange: (l: StoreListener) => void 18 | } 19 | 20 | /** 21 | * maps an arg name to a part of the redux store 22 | */ 23 | interface ArgSyncPathEntry { 24 | name: string 25 | path: string 26 | } 27 | 28 | interface ArgSyncSetEntry { 29 | name: string 30 | setter: (argValue: any, argValues: Args, state: State) => State 31 | } 32 | 33 | interface AddonReduxStore extends Store { 34 | __WITH_REDUX_ENABLED__?: AddonReduxEnabled 35 | } 36 | 37 | interface OnDispatchEvent { 38 | id: number 39 | date: Date 40 | action: AnyAction 41 | diff: string 42 | prev: string 43 | state: string 44 | } 45 | 46 | interface OnInitEvent { 47 | state: string 48 | } 49 | -------------------------------------------------------------------------------- /stories/SharedState.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { PARAM_REDUX_MERGE_STATE } from '../src/constants' 3 | import { SharedStateManager } from './SharedState' 4 | 5 | export default { 6 | title: 'Example/SharedState', 7 | component: SharedStateManager, 8 | parameters: { 9 | [PARAM_REDUX_MERGE_STATE]: { 10 | // note: even though Redux state is reset, the text in the store still gets updated 11 | // since PARAM_REDUX_MERGE_STATE happens after the state is reset 12 | text: 'this does not get reset' 13 | } 14 | } 15 | } 16 | 17 | const Template = (args) => 18 | 19 | export const Reader = Template.bind({}) 20 | Reader.args = { 21 | showButton: false 22 | } 23 | 24 | export const Writer = Template.bind({}) 25 | Writer.args = { 26 | showButton: true 27 | } 28 | Writer.parameters = { 29 | [PARAM_REDUX_MERGE_STATE]: { 30 | // note: even though Redux state is reset, the UI still shows 20 as the starting value 31 | // since PARAM_REDUX_MERGE_STATE happens after the state is reset 32 | counters: [ 33 | { name: 'test', count: 20 } 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Storybook contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /stories/page.css: -------------------------------------------------------------------------------- 1 | section { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-size: 14px; 4 | line-height: 24px; 5 | padding: 48px 20px; 6 | margin: 0 auto; 7 | max-width: 600px; 8 | color: #333; 9 | } 10 | 11 | h2 { 12 | font-weight: 900; 13 | font-size: 32px; 14 | line-height: 1; 15 | margin: 0 0 4px; 16 | display: inline-block; 17 | vertical-align: top; 18 | } 19 | 20 | p { 21 | margin: 1em 0; 22 | } 23 | 24 | a { 25 | text-decoration: none; 26 | color: #1ea7fd; 27 | } 28 | 29 | ul { 30 | padding-left: 30px; 31 | margin: 1em 0; 32 | } 33 | 34 | li { 35 | margin-bottom: 8px; 36 | } 37 | 38 | .tip { 39 | display: inline-block; 40 | border-radius: 1em; 41 | font-size: 11px; 42 | line-height: 12px; 43 | font-weight: 700; 44 | background: #e7fdd8; 45 | color: #66bf3c; 46 | padding: 4px 12px; 47 | margin-right: 10px; 48 | vertical-align: top; 49 | } 50 | 51 | .tip-wrapper { 52 | font-size: 13px; 53 | line-height: 20px; 54 | margin-top: 40px; 55 | margin-bottom: 40px; 56 | } 57 | 58 | .tip-wrapper svg { 59 | display: inline-block; 60 | height: 12px; 61 | width: 12px; 62 | margin-right: 4px; 63 | vertical-align: top; 64 | margin-top: 3px; 65 | } 66 | 67 | .tip-wrapper svg path { 68 | fill: #1ea7fd; 69 | } 70 | -------------------------------------------------------------------------------- /src/util/useSyncReduxArgs.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | import { EVENTS } from '../constants' 3 | import { useChannel, useArgs } from '@storybook/api' 4 | import useArgsSyncMapReduxPath from './useArgsSyncMapReduxPath' 5 | import useArgsSyncMapReduxSet from './useArgsSyncMapReduxSet' 6 | import { State } from 'src/typings' 7 | import { stringify } from '../util/jsonHelper' 8 | 9 | interface Entries { 10 | [name: string]: any 11 | } 12 | 13 | const useSyncReduxArgs = (state: State): void => { 14 | const emit = useChannel({}) 15 | const [args] = useArgs() 16 | const ref = useRef({}) 17 | const argSyncPathMap = useArgsSyncMapReduxPath() 18 | const argSyncSetMap = useArgsSyncMapReduxSet() 19 | 20 | argSyncPathMap.forEach(entry => { 21 | const value = args[entry.name] 22 | if (value !== ref.current[entry.name]) { 23 | ref.current[entry.name] = value 24 | setTimeout(() => emit(EVENTS.SET_STATE_AT_PATH, entry.path, value), 0) 25 | } 26 | }) 27 | 28 | argSyncSetMap.forEach(entry => { 29 | const value = args[entry.name] 30 | if (value !== ref.current[entry.name]) { 31 | ref.current[entry.name] = value 32 | setTimeout(() => { 33 | const newState: State = entry.setter(value, args, state) 34 | emit(EVENTS.SET_STATE, stringify(newState)) 35 | }, 0) 36 | } 37 | }) 38 | } 39 | 40 | export default useSyncReduxArgs 41 | -------------------------------------------------------------------------------- /src/util/set.ts: -------------------------------------------------------------------------------- 1 | const PATH_SEPARATOR = /[^.[\]]+/g 2 | 3 | const isNotObject = (o: any): boolean => Object(o) !== o 4 | 5 | const isArrayIndexable = (s: any): boolean => Math.abs(s) >> 0 === +s 6 | 7 | const seedObject = (nextPathPart: string): any => isArrayIndexable(nextPathPart) ? [] : {} 8 | 9 | const set = (obj: any, path: string, value: any): any => { 10 | if (path === null || path === undefined) return obj 11 | if (isNotObject(obj)) return obj 12 | const aPath = path.match(PATH_SEPARATOR) ?? [] 13 | const max = aPath.length - 1 14 | 15 | let clone: any 16 | let prevBranch: any 17 | let prevPath: string 18 | 19 | const reduceObjectBranch = (branch: any, pathPart: string, i: number): any => { 20 | const branchClone = Array.isArray(branch) ? [...branch] : { ...branch } 21 | 22 | // mutate cloned branch 23 | if (i === max) { 24 | branchClone[pathPart] = value 25 | } else if (isNotObject(branchClone[pathPart])) { 26 | branchClone[pathPart] = seedObject(aPath[i + 1]) 27 | } 28 | 29 | // update cloned object with the new branch 30 | if (i === 0) { 31 | clone = branchClone 32 | prevBranch = clone 33 | } else { 34 | prevBranch[prevPath] = branchClone 35 | prevBranch = branchClone 36 | } 37 | prevPath = pathPart 38 | 39 | // return the next branch for iteration 40 | return branchClone[pathPart] 41 | } 42 | 43 | aPath.reduce(reduceObjectBranch, obj) 44 | 45 | return clone 46 | } 47 | 48 | export default set 49 | -------------------------------------------------------------------------------- /src/components/ObjectEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback, useRef, RefObject } from 'react' 2 | import JSONEditor, { JSONEditorOptions } from 'jsoneditor' 3 | import 'jsoneditor/dist/jsoneditor.css' 4 | 5 | interface Props { 6 | value: object 7 | onChange: ChangeHandler 8 | } 9 | 10 | const equals = (a: any, b: any): boolean => JSON.stringify(a) === JSON.stringify(b) 11 | 12 | export type ChangeHandler = (value: any) => void 13 | 14 | interface UseEditorResult { 15 | containerRef: RefObject 16 | editorRef: RefObject 17 | } 18 | 19 | const useEditor = (onChange: ChangeHandler): UseEditorResult => { 20 | const containerRef = useRef(null) 21 | const editorRef = useRef() 22 | 23 | if ((editorRef.current == null) && (containerRef.current != null)) { 24 | const options: JSONEditorOptions = { 25 | mode: 'tree', 26 | onChangeJSON: onChange 27 | } 28 | editorRef.current = new JSONEditor(containerRef.current, options) 29 | } 30 | 31 | return { 32 | editorRef, 33 | containerRef 34 | } 35 | } 36 | 37 | const ObjectEditor: FC = ({ value, onChange }) => { 38 | const valueRef = useRef({}) 39 | 40 | const onChangeWrapper = useCallback(v => { 41 | valueRef.current = v 42 | onChange(v) 43 | }, []) 44 | 45 | const { editorRef, containerRef } = useEditor(onChangeWrapper) 46 | 47 | if (!equals(valueRef.current, value)) { 48 | valueRef.current = value 49 | editorRef.current?.update(value) 50 | } 51 | 52 | return ( 53 |
54 | ) 55 | } 56 | 57 | export default ObjectEditor 58 | -------------------------------------------------------------------------------- /stories/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Button } from './Button'; 5 | import './header.css'; 6 | 7 | export const Header = ({ user, onLogin, onLogout, onCreateAccount }) => ( 8 |
9 |
10 |
11 | 12 | 13 | 17 | 21 | 25 | 26 | 27 |

Acme

28 |
29 |
30 | {user ? ( 31 |
39 |
40 |
41 | ); 42 | 43 | Header.propTypes = { 44 | user: PropTypes.shape({}), 45 | onLogin: PropTypes.func.isRequired, 46 | onLogout: PropTypes.func.isRequired, 47 | onCreateAccount: PropTypes.func.isRequired, 48 | }; 49 | 50 | Header.defaultProps = { 51 | user: null, 52 | }; 53 | -------------------------------------------------------------------------------- /stories/Button.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector, useDispatch } from 'react-redux' 3 | import PropTypes from 'prop-types' 4 | import './button.css' 5 | // import ObjectEditor from '../dist/esm/components/ObjectEditor' 6 | 7 | const ButtonCounter = ({ name, count, id, size, primary }) => { 8 | const dispatch = useDispatch() 9 | const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary' 10 | const className = ['storybook-button', `storybook-button--${size}`, mode].join(' ') 11 | const mesg = `${name || 'Button'} (${count})` 12 | return ( 13 | 16 | ) 17 | } 18 | 19 | /** 20 | * Primary UI component for user interaction 21 | */ 22 | export const Button = ({ primary, backgroundColor, size, label, ...props }) => { 23 | const counters = useSelector(state => state.counters) 24 | return ( 25 | <> 26 | {counters.map(({ count, name }, i) => )} 27 | 28 | ) 29 | } 30 | 31 | Button.propTypes = { 32 | /** 33 | * Is this the principal call to action on the page? 34 | */ 35 | primary: PropTypes.bool, 36 | /** 37 | * What background color to use 38 | */ 39 | backgroundColor: PropTypes.string, 40 | /** 41 | * How large should the button be? 42 | */ 43 | size: PropTypes.oneOf(['small', 'medium', 'large']), 44 | /** 45 | * Button contents 46 | */ 47 | label: PropTypes.string, 48 | /** 49 | * Optional click handler 50 | */ 51 | onClick: PropTypes.func 52 | } 53 | 54 | Button.defaultProps = { 55 | backgroundColor: null, 56 | primary: false, 57 | size: 'medium', 58 | onClick: undefined 59 | } 60 | -------------------------------------------------------------------------------- /scripts/prepublish-checks.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | 3 | const packageJson = require("../package.json"); 4 | const boxen = require("boxen"); 5 | const dedent = require("dedent"); 6 | 7 | const name = packageJson.name; 8 | const displayName = packageJson.storybook.displayName; 9 | 10 | let exitCode = 0; 11 | $.verbose = false; 12 | 13 | /** 14 | * Check that meta data has been updated 15 | */ 16 | if (name.includes("addon-kit") || displayName.includes("Addon Kit")) { 17 | console.error( 18 | boxen( 19 | dedent` 20 | ${chalk.red.bold("Missing metadata")} 21 | 22 | ${chalk.red(dedent`Your package name and/or displayName includes default values from the Addon Kit. 23 | The addon gallery filters out all such addons. 24 | 25 | Please configure appropriate metadata before publishing your addon. For more info, see: 26 | https://storybook.js.org/docs/react/addons/addon-catalog#addon-metadata`)}`, 27 | { padding: 1, borderColor: "red" } 28 | ) 29 | ); 30 | 31 | exitCode = 1; 32 | } 33 | 34 | /** 35 | * Check that README has been updated 36 | */ 37 | const readmeTestStrings = 38 | "# Storybook Addon Kit|Click the \\*\\*Use this template\\*\\* button to get started.|https://user-images.githubusercontent.com/42671/106809879-35b32000-663a-11eb-9cdc-89f178b5273f.gif"; 39 | 40 | if ((await $`cat README.md | grep -E ${readmeTestStrings}`.exitCode) == 0) { 41 | console.error( 42 | boxen( 43 | dedent` 44 | ${chalk.red.bold("README not updated")} 45 | 46 | ${chalk.red(dedent`You are using the default README.md file that comes with the addon kit. 47 | Please update it to provide info on what your addon does and how to use it.`)} 48 | `, 49 | { padding: 1, borderColor: "red" } 50 | ) 51 | ); 52 | 53 | exitCode = 1; 54 | } 55 | 56 | process.exit(exitCode); 57 | -------------------------------------------------------------------------------- /src/preset/manager.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { addons, types } from '@storybook/addons' 3 | import { AddonPanel } from '@storybook/components' 4 | 5 | import StateView from '../components/StateView' 6 | import { PanelComponent } from '../typings' 7 | import { color } from '@storybook/theming' 8 | import { ADDON_ID, PANEL_ID_HISTORY, PANEL_ID_STORE } from '../constants' 9 | import HistoryView from '../components/HistoryView' 10 | 11 | const injectCss = (): void => { 12 | var css = ` 13 | .addon-redux-editor .jsoneditor { 14 | border: none; 15 | } 16 | 17 | .addon-redux-editor .jsoneditor-menu { 18 | background-color: ${color.secondary}; 19 | border-bottom: none; 20 | border-top: none; 21 | } 22 | 23 | .addon-redux-editor .jsoneditor-contextmenu .jsoneditor-menu { 24 | background: #ffffff; 25 | } 26 | 27 | .jsoneditor-modal .pico-modal-header { 28 | background: ${color.secondary}; 29 | } 30 | ` 31 | var head = document.getElementsByTagName('head')[0] 32 | var style = document.createElement('style') 33 | head.appendChild(style) 34 | style.appendChild(document.createTextNode(css)) 35 | } 36 | 37 | const StorePanel: PanelComponent = (props) => 38 | 39 | 40 | 41 | 42 | const HistoryPanel: PanelComponent = (props) => 43 | 44 | 45 | 46 | 47 | addons.register(ADDON_ID, () => { 48 | injectCss() 49 | 50 | addons.add(PANEL_ID_STORE, { 51 | type: types.PANEL, 52 | title: 'Redux Store', 53 | match: ({ viewMode }) => viewMode === 'story', 54 | render: StorePanel 55 | }) 56 | addons.add(PANEL_ID_HISTORY, { 57 | type: types.PANEL, 58 | title: 'Redux History', 59 | match: ({ viewMode }) => viewMode === 'story', 60 | render: HistoryPanel 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /stories/Button.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Button } from './Button' 3 | import { PARAM_REDUX_MERGE_STATE, ARG_REDUX_PATH, ARG_REDUX_SET_STATE } from '../src/constants' 4 | 5 | export default { 6 | title: 'Example/Button', 7 | component: Button, 8 | parameters: { 9 | [PARAM_REDUX_MERGE_STATE]: { 10 | counters: [ 11 | { name: 'First Button', count: 0 }, 12 | { name: 'Second Button', count: 10 }, 13 | { name: 'Third Button', count: 20 } 14 | ] 15 | } 16 | }, 17 | argTypes: { 18 | setAll: { 19 | control: { type: 'number' }, 20 | [ARG_REDUX_SET_STATE]: (value, args, state) => { 21 | if (!state) return state 22 | const counters = state.counters.map(c => ({ ...c, count: value })) 23 | return { 24 | ...state, 25 | counters 26 | } 27 | } 28 | }, 29 | name1: { 30 | control: { type: 'text' }, 31 | [ARG_REDUX_PATH]: 'counters.0.name' 32 | }, 33 | count1: { 34 | control: { type: 'number' }, 35 | [ARG_REDUX_PATH]: 'counters.0.count' 36 | }, 37 | name2: { 38 | control: { type: 'text' }, 39 | [ARG_REDUX_PATH]: 'counters.1.name' 40 | }, 41 | count2: { 42 | control: { type: 'number' }, 43 | [ARG_REDUX_PATH]: 'counters.1.count' 44 | } 45 | } 46 | } 47 | 48 | const Template = (args) => 101 | 102 | ) 103 | } 104 | 105 | const HistoryView: FC<{}> = () => { 106 | const [events, setEvents] = useAddonState(STATE_ID_HISTORY, []) 107 | 108 | const emit = useChannel({ 109 | [EVENTS.ON_DISPATCH]: (ev: OnDispatchEvent) => { 110 | if (Object.values(ACTIONS_TYPES).includes(ev.action.type)) { 111 | return 112 | } 113 | setEvents(events => reducer(events, ev)) 114 | } 115 | }) 116 | 117 | return ( 118 | 119 | 120 |
121 | 122 | 123 | {events.map(event => )} 124 | 125 | 126 | ) 127 | } 128 | 129 | export default HistoryView 130 | -------------------------------------------------------------------------------- /docs/addon-redux.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 56 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Redux Addon](docs/addon-redux-header.png) 2 | 3 | `addon-redux` is a [Storybook](https://storybook.js.org) addon that helps when building stories using components that use redux state. 4 | 5 | Ideally stories are only needed for non-redux connected components, not containers. However, when writing stories for components of a redux application, it is common for the components to have conatiners as children which causes problems. This is where `addon-redux` helps out by providing a decorator and helpful panels to support container components. 6 | 7 | 8 | This documentation is for version 2, [click here](docs/v1/README.md) for information on setting up version 1. 9 | 10 | 11 | 12 | ![Redux Addon State Panel](docs/v2/addon-redux-state-panel.png?v=1) 13 | 14 | [__Demo__](https://github.com/frodare/addon-redux-example) project using the Redux Addon. 15 | 16 | This addon is compatible with Storybook for React 17 | 18 | # Features 19 | 20 | - Add two panels to Storybook: Redux State and Redux History 21 | - Wraps stories with a React Redux Provider component 22 | - View and Edit the current state of the store 23 | - Resets redux to initial state when switching stories 24 | - Provides a story parameter to update the store on story load 25 | - Logs actions and maintains the time, action, previous state, next state and state diff 26 | - Supports time travel to previous states 27 | 28 | # Install 29 | 30 | ``` 31 | npm install addon-redux 32 | ``` 33 | 34 | # Usage 35 | 36 | In order for the React Redux addon to function correctly: 37 | - it must be [registered](#register) as a Storybook addon 38 | - its store [enhancer](#enhancer) must be used in the app's store 39 | - the store must be [imported](#import-the-store-previewjs) in preview.js 40 | 41 | ## Register 42 | 43 | Similar to other Storybook addons, the Redux Addon needs to be registered with storybook before use. 44 | 45 | ```js 46 | // .storybook/main.js 47 | module.exports = { 48 | stories: [ 49 | "../src/**/*.stories.mdx", 50 | "../src/**/*.stories.@(js|jsx|ts|tsx)" 51 | ], 52 | addons: [ 53 | "@storybook/addon-links", 54 | "@storybook/addon-essentials", 55 | "@storybook/preset-create-react-app", 56 | "addon-redux" 57 | ] 58 | } 59 | ``` 60 | 61 | ## Enhancer 62 | 63 | To give the Redux Addon the ability to listen to and alter the store, its enhancer must be used when creating the store as shown below. 64 | 65 | ```js 66 | // Simplest use of the Redux Addon Enhancer 67 | import { createStore, compose } from 'redux' 68 | import reducer from './your/reducer' 69 | import { enhancer } from 'addon-redux' 70 | 71 | export const store = createStore(reducer, {}, enhancer) 72 | ``` 73 | 74 | Usually the store of an application will already be using enhancers. A more realistic store setup for the Redux Addon is shown below. 75 | It includes the commonly used middleware enhancer along with some middlewares for demonstration. 76 | This example shows how the Redux enhancer can be used with other enhancers, although the store creation code can look very different between different applications. 77 | 78 | ```js 79 | // Realistic use of the Redux Addon Enhancer with other store enhancers 80 | import { createStore, compose, applyMiddleware } from 'redux' 81 | import { enhancer as withReduxEnhancer } from 'addon-redux' 82 | import reducer from './your/reducer' 83 | import createMiddlewareEnhancer from './middlewareEnhancer' 84 | import invariant from 'redux-immutable-state-invariant' 85 | import logger from 'redux-logger' 86 | 87 | createMiddlewareEnhancer () => { 88 | const middleware = [] 89 | if (process.env.NODE_ENV !== 'production') { 90 | // include other middlewares as needed, like the invariant and logger middlewares 91 | middleware.push(invariant()) 92 | middleware.push(logger()) 93 | } 94 | return applyMiddleware(...middleware) 95 | } 96 | 97 | const createEnhancer = () => { 98 | const enhancers = [] 99 | enhancers.push(createMiddlewareEnhancer()) 100 | if (process.env.NODE_ENV !== 'production') { 101 | enhancers.push(withReduxEnhancer) 102 | } 103 | return compose(...enhancers) 104 | } 105 | 106 | const store = createStore(reducer, createEnhancer()) 107 | 108 | export default store 109 | ``` 110 | 111 | ## Import the Store (Preview.js) 112 | 113 | The store must be imported in `./storybook/preivew.js` so that it will be setup and ready for the stories. 114 | This addon will automatically wrap stories with the Redux provider as long as the enhancer has been setup as shown above. 115 | 116 | This can be done in two ways: 117 | 118 | 1. Import the store at the top of your file synchronously 119 | 120 | ```js 121 | // .storybook/preview.js 122 | 123 | const store = require('./your/store') 124 | 125 | module.exports = { 126 | decorators: [] 127 | } 128 | ``` 129 | 130 | 2. Import the store asynchronously using [Storybook loaders](https://storybook.js.org/docs/react/writing-stories/loaders) 131 | 132 | ```js 133 | // .storybook/preview.js 134 | 135 | module.exports = { 136 | decorators: [], 137 | loaders: [ 138 | async () => ({ 139 | store: await import('../stories/store'), 140 | }) 141 | ] 142 | }; 143 | 144 | ``` 145 | 146 | ![Redux Addon History Panel](docs/v2/addon-redux-history-panel.png?v=1) 147 | 148 | ## Args 149 | 150 | Further control of the redux state is provided using [storybook args](https://storybook.js.org/docs/react/writing-stories/args). Args can be linked to the redux store using the `ARG_REDUX_PATH` key in the `argTypes` key of the default CSF export. The value of the `ARG_REDUX_PATH` is a dot delimited string representing the path that the arg corresponds to in the store. Integer segments are treated as array indices. 151 | 152 | ```js 153 | import React from 'react' 154 | import App from './App' 155 | import { ARG_REDUX_PATH } from 'addon-redux' 156 | 157 | export default { 158 | title: 'App', 159 | component: App, 160 | argTypes: { 161 | name1: { 162 | control: { type: 'text' }, 163 | [ARG_REDUX_PATH]: 'todos.0.text' 164 | } 165 | } 166 | }; 167 | 168 | const Template = (args) => ; 169 | 170 | export const All = Template.bind({}); 171 | All.args = { 172 | name1: 'First Value', 173 | completed1: false 174 | }; 175 | ``` 176 | 177 | ## Parameters 178 | 179 | `addon-redux` currently supports one [storybook parameter](https://storybook.js.org/docs/react/writing-stories/parameters) that can be used to change the redux state on story load, `PARAM_REDUX_MERGE_STATE`. This parameter takes a JSON string or object that will be parsed and spread on top of the current store's state. 180 | 181 | ```js 182 | // example story using PARAM_REDUX_MERGE_STATE 183 | import React from 'react' 184 | import MyComponent from './MyComponent' 185 | import { PARAM_REDUX_MERGE_STATE } from 'addon-redux' 186 | 187 | export default { 188 | title: 'MyComponent', 189 | component: MyComponent, 190 | parameters: { 191 | [PARAM_REDUX_MERGE_STATE]: '{"foo": {"bar": "baz"}}' 192 | } 193 | }; 194 | 195 | const Template = (args) => ; 196 | 197 | export const All = Template.bind({}); 198 | All.args = {}; 199 | ``` 200 | 201 | -------------------------------------------------------------------------------- /docs/v1/README.md: -------------------------------------------------------------------------------- 1 | # Storybook Redux Addon 2 | 3 | Storybook Redux Addon aids in using redux backed components in your stories in [Storybook](https://storybook.js.org). 4 | 5 | Ideally stories are only needed for non-redux connected components, not containers. However, when writing stories for components of a redux application, it is common for the components to have conatiners as children which causes problems. This is where the Redux Addon helps out by providing a decorator and helpful panels to support container components. 6 | 7 | --- 8 | 9 | ![Redux Addon History Panel](addon-redux-history-panel.png?v=1) 10 | 11 | [__Demo__](https://github.com/frodare/addon-redux-example) project using the Redux Addon. 12 | 13 | This addon is compatible with Storybook for React 14 | 15 | # Features 16 | 17 | - Add two panels to Storybook: Redux State and Redux History 18 | - Wraps stories with a React Redux Provider component 19 | - View and Edit the current state of the store 20 | - Provides Canned Action buttons which can dispatch predefined actions 21 | - Logs actions and maintains the time, action, previous state, next state and state diff 22 | - Supports time travel to previous states 23 | - Filter actions entries by action and state diff 24 | 25 | # Install 26 | 27 | ``` 28 | npm install --save-dev addon-redux 29 | ``` 30 | 31 | # Usage 32 | 33 | In order for the React Redux addon to function correctly: 34 | - it must be [registered](#register) as a Storybook addon 35 | - its store [enhancer](#enhancer) must be used in the provided store 36 | - the [withRedux](#decorator-withredux) decorator must be used in the story 37 | 38 | ## Register 39 | 40 | Similar to other Storybook addons, the Redux Addon needs to be registered with storybook before use. 41 | However, the Redux Addon also requires the `addons` module as shown below. 42 | 43 | ```js 44 | // .storybook/addons.js 45 | import addons from '@storybook/addons' 46 | import registerRedux from 'addon-redux/register' 47 | registerRedux(addons) 48 | ``` 49 | 50 | ## Enhancer 51 | 52 | To give the Redux Addon the ability to listen to and alter the store, its enhancer must be used when creating the store as shown below. 53 | 54 | ```js 55 | // Simplest use of the Redux Addon Enhancer 56 | import { createStore, compose } from 'redux' 57 | import reducer from './your/reducer' 58 | import withReduxEnhancer from 'addon-redux/enhancer' 59 | 60 | const store = createStore(reducer, withReduxEnhancer) 61 | 62 | export default store 63 | ``` 64 | 65 | Usually the store of an application will already be using enhancers. A more realistic store setup for the Redux Addon is shown below. 66 | It includes the commonly used middleware enhancer along with some middlewares for demonstration. 67 | This example shows how the Redux enhancer can be used with other enhancers, although the store creation code can look very different between different applications. 68 | 69 | ```js 70 | // Realistic use of the Redux Addon Enhancer 71 | import { createStore, compose } from 'redux' 72 | import reducer from './your/reducer' 73 | import createMiddlewareEnhancer from './middlewareEnhancer' 74 | import withReduxEnhancer from 'addon-redux/enhancer' 75 | import { applyMiddleware } from 'redux' 76 | import invariant from 'redux-immutable-state-invariant' 77 | import logger from 'redux-logger' 78 | 79 | createMiddlewareEnhancer () => { 80 | const middleware = [] 81 | if (process.env.NODE_ENV !== 'production') { 82 | // include other middlewares as needed, like the invariant and logger middlewares 83 | middleware.push(invariant()) 84 | middleware.push(logger()) 85 | } 86 | return applyMiddleware(...middleware) 87 | } 88 | 89 | const createEnhancer = () => { 90 | const enhancers = [] 91 | enhancers.push(createMiddlewareEnhancer()) 92 | if (process.env.NODE_ENV !== 'production') { 93 | enhancers.push(withReduxEnhancer) 94 | } 95 | return compose(...enhancers) 96 | } 97 | 98 | const store = createStore(reducer, createEnhancer()) 99 | 100 | export default store 101 | ``` 102 | 103 | ## Decorator (withRedux) 104 | 105 | The Redux Addon provides a decorator that wraps stories with a react redux provider component. 106 | The provided `withRedux` method is a factory that requires storybook's `addons` module. 107 | The result then needs to be invoked with the redux addon settings for the story. 108 | There are currently three supported settings: __store__, __state__, and __actions__. 109 | 110 | ```js 111 | import React from 'react' 112 | import { Provider } from 'react-redux' 113 | import { storiesOf } from '@storybook/react' 114 | import addons from '@storybook/addons' 115 | import withRedux from 'addon-redux/withRedux' 116 | import store from './your/store' 117 | import Container from './your/container' 118 | 119 | const withReduxSettings = { 120 | Provider, 121 | store, 122 | state: {optional: 'state to merge on start'}, 123 | actions: [{name: 'Demo Action', action: {type: 'test'}}] 124 | } 125 | 126 | const withReduxDecorator = withRedux(addons)(withReduxSettings) 127 | 128 | const stories = storiesOf('Demo', module) 129 | stories.addDecorator(withReduxDecorator) 130 | stories.add('default', () => 131 | ``` 132 | 133 | ### Store Setting 134 | The __store__ setting is required and should be set with the store of your application. 135 | To function properly, the store must be built including the [enhancer](#enhancer) provided with the Redux Addon. 136 | 137 | ### State Setting 138 | The __state__ setting is optional. Use it if the default state returned from the store's reducers is not ideal or can be improved for the story. 139 | The object set to the __store__ setting will be merged on top of the default store's state when the story loads. 140 | 141 | ### Canned Actions Setting 142 | The __actions__ setting is optional. Use it to set canned (predefined) actions that are useful to test the story's component. 143 | The setting takes an array of canned action objects which contain a name and action key. 144 | A button will be appended to the Redux State Panel for each canned action object in the array. 145 | The name key is used as the label of a button. 146 | The action key holds the action object that will be dispatched when the canned action button is clicked. 147 | 148 | ```js 149 | // Canned Action Object 150 | { 151 | name: 'Test Action', 152 | action: { 153 | type: 'test_action_type', 154 | date: 'action data' 155 | } 156 | } 157 | 158 | ``` 159 | 160 | ![Redux Addon State Panel](addon-redux-state-panel.png?v=1) 161 | 162 | ## Custom Decorator 163 | 164 | Having to import the `store`, `Provider`, `addons` module, and the `withRedux` decorator in every story file is not ideal for larger applications. Instead, it is recomended to create a custom decorator in the storybook configuration folder that includes the `withRedux` setup. 165 | 166 | ```js 167 | // .storybook/decorators.js 168 | import React from 'react' 169 | import { Provider } from 'react-redux' 170 | import addons from '@storybook/addons' 171 | import withReduxCore from 'addon-redux/withRedux' 172 | import { store } from './your/store' 173 | 174 | export const withRedux = (state, actions) => withReduxCore(addons)({ 175 | Provider, store, state, actions 176 | }) 177 | ``` 178 | 179 | ```js 180 | //custom decorator use in story 181 | import { withRedux } from '../../.storybook/decorators' 182 | ... 183 | stories.addDecorator(withRedux(initialState, cannedActions)) 184 | ``` 185 | -------------------------------------------------------------------------------- /docs/addon-redux-header.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 40 | 42 | 43 | 45 | image/svg+xml 46 | 48 | 49 | 50 | 51 | 52 | 57 | 64 | Addon Redux 75 | 76 | 77 | --------------------------------------------------------------------------------