├── .babelrc ├── .gitignore ├── .npmignore ├── .storybook ├── addons.js └── config.js ├── LICENSE ├── README.md ├── index.d.ts ├── package-lock.json ├── package.json ├── panel-screenshot.png ├── register.js └── src ├── constants.js ├── index.js ├── index.test.js └── register.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "modules": false }], 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | "@babel/plugin-transform-runtime", 8 | "@babel/plugin-proposal-class-properties", 9 | "@babel/plugin-proposal-object-rest-spread" 10 | ], 11 | "env": { 12 | "test": { 13 | "presets": [ 14 | ["@babel/preset-env", { 15 | "targets": { "node": "current" } 16 | }] 17 | ] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | dist/ 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dump247/storybook-state/1da90ad201c95b0f8e5a9f1781f1b8f1aa821bae/.npmignore -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '../register'; 2 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure, storiesOf } from '@storybook/react'; 3 | import { withState } from '../dist/index'; 4 | import { withInfo } from '@storybook/addon-info'; 5 | 6 | const Input = (props) => { 7 | return props.store.set({ value })}/>; 8 | }; 9 | 10 | configure(function () { 11 | storiesOf('Test', module) 12 | .add('with state', withState({ value: '' }, (store) => )) 13 | .add('with state 2', withState({ value: '' }, (store) => )) 14 | .add('no state', () =>
No stuff
) 15 | .add('chain', withState({ value: '' })(({ store }) => )) 16 | .add('chain withInfo before', 17 | withInfo('some info')(withState({ value: '' })(({ store }) => )) 18 | ) 19 | .add('chain withInfo after', 20 | withState({ value: '' })(withInfo('some info')(({ store }) => )) 21 | ); 22 | }, module); 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Your Name. 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Storybook State 2 | 3 | *This project is no longer being maintained. The recommended replacement is the useState hook in React 16+.* 4 | 5 | An extension for [Storybook](https://storybook.js.org/) that manages the state of a stateless 6 | component. This makes it easier to write stories for stateless components. 7 | 8 | ## Getting Started 9 | 10 | ### Add @dump247/storybook 11 | 12 | ```sh 13 | npm install --save-dev @dump247/storybook-state 14 | ``` 15 | 16 | Register the extension in `addons.js`. 17 | 18 | ```javascript 19 | import '@dump247/storybook-state/register'; 20 | ``` 21 | 22 | ### Create a Story 23 | 24 | Use the extension to create a story. 25 | 26 | ```jsx 27 | import React from 'react'; 28 | import { storiesOf } from '@storybook/react'; 29 | import { withState } from '@dump247/storybook-state'; 30 | 31 | storiesOf('Checkbox', module).add( 32 | 'with check', 33 | withState({ checked: false })(({ store }) => ( 34 | store.set({ checked })} 38 | /> 39 | )), 40 | ); 41 | ``` 42 | 43 | ## Extension 44 | 45 | ### `withState(initialState)(storyFn)` 46 | 47 | `initialState` is the initial state of the component. This is an object where each key is a 48 | state value to set. 49 | 50 | `storyFn` is the function that produces the story component. This function receives the story context 51 | object `{ store: Store }` as the parameter. 52 | 53 | This extension can be composed with other storybook extension functions: 54 | 55 | ```jsx 56 | withState({ initialState: '' })( 57 | withInfo(`Some cool info`)( 58 | ({ store }) => 59 | ) 60 | ) 61 | ``` 62 | 63 | 64 | ## Store API 65 | 66 | ### `store.state` 67 | 68 | Object that contains the current state. 69 | 70 | ### `store.set(state)` 71 | 72 | Set the given state keys. The `state` parameter is an object with the keys and values to set. 73 | 74 | This only sets/overwrites the specific keys provided. 75 | 76 | ### `store.reset()` 77 | 78 | Reset the store to the initial state. 79 | 80 | ## Panel 81 | 82 | This project includes a storybook panel that displays the current state and allows 83 | for resetting the state. 84 | 85 | ![Panel Screenshot](panel-screenshot.png?raw=true&v=2 "Panel") 86 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Renderable, RenderFunction } from '@storybook/react'; 2 | export type Store = { 3 | /** 4 | * @description 5 | * Object that contains the current state. 6 | */ 7 | state: T; 8 | 9 | /** 10 | * @description 11 | * Set the given state keys. The state parameter is an object with the keys and values to set. 12 | * This only sets/overwrites the specific keys provided. 13 | */ 14 | set(nextState: Partial): void; 15 | 16 | /** 17 | * @description 18 | * Reset the store to the initial state. 19 | */ 20 | reset(): void; 21 | }; 22 | 23 | export type LegacyStorybookStateCallback = (store: Store) => Renderable | Renderable[]; 24 | export function withState(state: T, callback: LegacyStorybookStateCallback): any // RenderFunction; 25 | 26 | export type StorybookStateCallback = (context: {store: Store}) => Renderable | Renderable[]; 27 | export function withState(state: T): (callback: StorybookStateCallback) => any 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dump247/storybook-state", 3 | "version": "1.6.1", 4 | "description": "Manage component state in storybook stories.", 5 | "main": "dist/index.js", 6 | "typings": "index.d.ts", 7 | "scripts": { 8 | "build": "rm -rf dist/ && cd src/ && babel index.js register.js constants.js --out-dir ../dist", 9 | "dist": "npm run build && npm publish", 10 | "test": "jest", 11 | "storybook": "npm run build && start-storybook -p 9001 -c .storybook" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+ssh://git@github.com/dump247/storybook-state.git" 16 | }, 17 | "keywords": [ 18 | "react", 19 | "storybook" 20 | ], 21 | "author": "Cory Thomas ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/dump247/storybook-state/issues" 25 | }, 26 | "homepage": "https://github.com/dump247/storybook-state#readme", 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "peerDependencies": { 31 | "@storybook/addons": "^3.2.16", 32 | "prop-types": "^15.5.0", 33 | "react": "^15.5.0 || ^16.0.0" 34 | }, 35 | "dependencies": { 36 | "react": "^16.6.0", 37 | "react-json-view": "^1.13.3" 38 | }, 39 | "devDependencies": { 40 | "@babel/cli": "^7.4.4", 41 | "@babel/core": "^7.4.4", 42 | "@babel/plugin-proposal-class-properties": "^7.4.4", 43 | "@babel/plugin-proposal-object-rest-spread": "^7.4.4", 44 | "@babel/plugin-transform-runtime": "^7.4.4", 45 | "@babel/preset-env": "^7.4.4", 46 | "@babel/preset-react": "^7.0.0", 47 | "@storybook/addon-info": "^5.0.11", 48 | "@storybook/react": "^5.0.11", 49 | "@types/storybook__react": "^4.0.1", 50 | "enzyme": "^3.9.0", 51 | "enzyme-adapter-react-16": "^1.12.1", 52 | "jest": "^24.8.0", 53 | "react-dom": "^16.8.6", 54 | "react-test-renderer": "^16.8.6" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /panel-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dump247/storybook-state/1da90ad201c95b0f8e5a9f1781f1b8f1aa821bae/panel-screenshot.png -------------------------------------------------------------------------------- /register.js: -------------------------------------------------------------------------------- 1 | require('./dist/register').register(); 2 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const ADDON_ID = 'dump247/state'; 2 | export const ADDON_PANEL_ID = `${ADDON_ID}/panel`; 3 | export const ADDON_EVENT_CHANGE = `${ADDON_ID}/change`; 4 | export const ADDON_EVENT_RESET = `${ADDON_ID}/reset`; 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import T from 'prop-types'; 3 | import addons from '@storybook/addons'; 4 | import { 5 | ADDON_EVENT_CHANGE, 6 | ADDON_EVENT_RESET, 7 | } from './constants'; 8 | 9 | export class Store { 10 | constructor(initialState) { 11 | this.initialState = Object.freeze({ ...initialState }); 12 | this.state = this.initialState; 13 | this.handlers = []; 14 | } 15 | 16 | set(state) { 17 | this.state = Object.freeze({ ...this.state, ...state }); 18 | this.fireStateChange(); 19 | } 20 | 21 | reset() { 22 | if (this.initialState !== this.state) { 23 | this.state = this.initialState; 24 | this.fireStateChange(); 25 | } 26 | } 27 | 28 | subscribe(handler) { 29 | if (this.handlers.indexOf(handler) < 0) { 30 | this.handlers.push(handler); 31 | } 32 | } 33 | 34 | unsubscribe(handler) { 35 | const handlerIndex = this.handlers.indexOf(handler); 36 | if (handlerIndex >= 0) { 37 | this.handlers.splice(handlerIndex, 1); 38 | } 39 | } 40 | 41 | fireStateChange() { 42 | const state = this.state; 43 | 44 | this.handlers.forEach(handler => handler(state)); 45 | } 46 | } 47 | 48 | export class StoryState extends React.Component { 49 | static propTypes = { 50 | channel: T.object.isRequired, 51 | store: T.object.isRequired, 52 | storyFn: T.func.isRequired, 53 | context: T.object, 54 | }; 55 | 56 | state = { 57 | storyState: this.props.store.state, 58 | }; 59 | 60 | componentDidMount() { 61 | const { store, channel } = this.props; 62 | 63 | store.subscribe(this.handleStateChange); 64 | channel.on(ADDON_EVENT_RESET, this.handleResetEvent); 65 | channel.emit(ADDON_EVENT_CHANGE, { state: store.state }); 66 | } 67 | 68 | componentWillUnmount() { 69 | const { store, channel } = this.props; 70 | 71 | store.unsubscribe(this.handleStateChange); 72 | channel.removeListener(ADDON_EVENT_RESET, this.handleResetEvent); 73 | channel.emit(ADDON_EVENT_CHANGE, { state: null }); 74 | } 75 | 76 | handleResetEvent = () => { 77 | const { store } = this.props; 78 | 79 | store.reset(); 80 | }; 81 | 82 | handleStateChange = (storyState) => { 83 | const { channel } = this.props; 84 | 85 | this.setState({ storyState }); 86 | channel.emit(ADDON_EVENT_CHANGE, { state: storyState }); 87 | }; 88 | 89 | render() { 90 | const { store, storyFn, context } = this.props; 91 | 92 | const child = context ? storyFn(context) : storyFn(store); 93 | return React.isValidElement(child) ? child : child(); 94 | } 95 | } 96 | 97 | export function withState(initialState, storyFn=null) { 98 | const store = new Store(initialState || {}); 99 | const channel = addons.getChannel(); 100 | 101 | if (storyFn) { 102 | // Support legacy withState signature 103 | return () => ( 104 | 105 | ); 106 | } else { 107 | return (storyFn) => (context) => ( 108 | 109 | ); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withState, Store } from './index'; 3 | import { mount, configure } from 'enzyme'; 4 | import Adapter from 'enzyme-adapter-react-16'; 5 | import addons from '@storybook/addons'; 6 | 7 | jest.mock('@storybook/addons'); 8 | 9 | function expectProps(component) { 10 | const props = component.props(); 11 | return expect(Object.keys(props) 12 | .filter(k => !k.startsWith('on')) 13 | .reduce((o, k) => { o[k] = props[k]; return o; }, {}) 14 | ); 15 | } 16 | 17 | function mockChannel() { 18 | return { emit: jest.fn(), on: jest.fn(), removeListener: jest.fn() }; 19 | } 20 | 21 | addons.getChannel.mockReturnValue(mockChannel()); 22 | 23 | configure({ adapter: new Adapter() }); 24 | 25 | describe('Store', () => { 26 | test('constructor initializes state', () => { 27 | const store = new Store({ var1: 'value 1' }); 28 | 29 | expect(store.state).toEqual({ var1: 'value 1' }); 30 | }); 31 | 32 | test('state is immutable', () => { 33 | const store = new Store({ var1: 'value 1' }); 34 | 35 | store.var1 = 'value 2'; 36 | expect(store.state).toEqual({ var1: 'value 1' }); 37 | }); 38 | 39 | test('set and reset', () => { 40 | const store = new Store({ var1: 'value 1' }); 41 | 42 | store.set({ var2: 'value 2' }); 43 | expect(store.state).toEqual({ var1: 'value 1', var2: 'value 2' }); 44 | 45 | store.set({ var1: 'value 3' }); 46 | expect(store.state).toEqual({ var1: 'value 3', var2: 'value 2' }); 47 | 48 | store.set({ var1: 'value 4', var2: 'value 5', var3: 'value 6' }); 49 | expect(store.state).toEqual({ var1: 'value 4', var2: 'value 5', var3: 'value 6' }); 50 | 51 | store.reset(); 52 | expect(store.state).toEqual({ var1: 'value 1' }); 53 | }); 54 | 55 | test('subscribe and unsubscribe', () => { 56 | const handler = jest.fn(); 57 | const store = new Store({ var1: 'value 1' }); 58 | store.subscribe(handler); 59 | 60 | store.set({ var1: 'value 2' }); 61 | expect(handler.mock.calls[0][0]).toEqual({ var1: 'value 2' }); 62 | 63 | store.reset(); 64 | expect(handler.mock.calls[1][0]).toEqual({ var1: 'value 1' }); 65 | 66 | // Second reset does nothing 67 | store.reset(); 68 | expect(handler.mock.calls.length).toBe(2); 69 | 70 | store.unsubscribe(handler); 71 | store.set({ var1: 'value 3' }); 72 | expect(handler.mock.calls.length).toBe(2); 73 | expect(store.state).toEqual({ var1: 'value 3' }); 74 | }); 75 | }); 76 | 77 | describe('withState', () => { 78 | const TestComponent = (props) => { 79 | return ( 80 |
81 | 82 | 83 | 84 |
85 | ); 86 | }; 87 | 88 | test('legacy signature', () => { 89 | const stateComponent = mount(withState({ var1: 1 }, (store) => )()); 90 | const testComponent = stateComponent.childAt(0); 91 | expect(testComponent.props()).toEqual({ var1: 1 }); 92 | }); 93 | 94 | test('initial state is passed to component', () => { 95 | const stateComponent = mount(withState({ var1: 1 })(({ store }) => )({})); 96 | const testComponent = stateComponent.childAt(0); 97 | expect(testComponent.props()).toEqual({ var1: 1 }); 98 | }); 99 | 100 | test('set existing state variable', () => { 101 | const stateComponent = mount(withState({ var1: 1 })(({ store }) => ( 102 | store.set({ var1: 2 })}/> 103 | ))({})); 104 | 105 | expectProps(stateComponent.childAt(0)).toEqual({ var1: 1 }); 106 | 107 | stateComponent.childAt(0).find('#setvar1').simulate('click'); 108 | 109 | expectProps(stateComponent.childAt(0)).toEqual({ var1: 2 }); 110 | }); 111 | 112 | test('set new state variable', () => { 113 | const stateComponent = mount(withState({ var1: 1 })(({ store }) => ( 114 | store.set({ var2: 3 })}/> 115 | ))({})); 116 | 117 | expectProps(stateComponent.childAt(0)).toEqual({ var1: 1 }); 118 | 119 | stateComponent.childAt(0).find('#setvar2').simulate('click'); 120 | 121 | expectProps(stateComponent.childAt(0)).toEqual({ var1: 1, var2: 3 }); 122 | }); 123 | 124 | test('reset to initial state', () => { 125 | const stateComponent = mount(withState({ var1: 1 })(({ store }) => ( 126 | store.set({ var2: 3 })} 128 | onSetVar1={() => store.set({ var1: 2 })} 129 | onReset={() => store.reset()}/> 130 | ))({})); 131 | 132 | expect(stateComponent.childAt(0).props()).toMatchObject({ var1: 1 }); 133 | 134 | stateComponent.childAt(0).find('#setvar1').simulate('click'); 135 | stateComponent.childAt(0).find('#setvar2').simulate('click'); 136 | 137 | expectProps(stateComponent.childAt(0)).toEqual({ var1: 2, var2: 3 }); 138 | 139 | stateComponent.childAt(0).find('#reset').simulate('click'); 140 | 141 | expectProps(stateComponent.childAt(0)).toEqual({ var1: 1 }); 142 | }); 143 | 144 | test('unmount state component', () => { 145 | const stateComponent = mount(withState({ var1: 1 })((store) => )({})); 146 | stateComponent.unmount(); 147 | }) 148 | }); 149 | -------------------------------------------------------------------------------- /src/register.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import T from 'prop-types'; 3 | import addons from '@storybook/addons'; 4 | import JsonView from 'react-json-view'; 5 | import { 6 | ADDON_ID, 7 | ADDON_PANEL_ID, 8 | ADDON_EVENT_CHANGE, 9 | ADDON_EVENT_RESET, 10 | } from './constants'; 11 | 12 | const styles = { 13 | panel: { 14 | margin: 10, 15 | fontFamily: 'Arial', 16 | fontSize: 14, 17 | color: '#444', 18 | width: '100%', 19 | overflow: 'auto', 20 | }, 21 | currentState: { 22 | whiteSpace: 'pre', 23 | }, 24 | resetButton: { 25 | position: 'absolute', 26 | bottom: 11, 27 | right: 10, 28 | border: 'none', 29 | borderTop: 'solid 1px rgba(0, 0, 0, 0.2)', 30 | borderLeft: 'solid 1px rgba(0, 0, 0, 0.2)', 31 | background: 'rgba(255, 255, 255, 0.5)', 32 | padding: '5px 10px', 33 | borderRadius: '4px 0 0 0', 34 | color: 'rgba(0, 0, 0, 0.5)', 35 | outline: 'none', 36 | }, 37 | }; 38 | 39 | class StatePanel extends React.Component { 40 | static propTypes = { 41 | channel: T.object, 42 | api: T.object, 43 | active: T.bool.isRequired, 44 | }; 45 | 46 | state = { 47 | storyState: null, 48 | }; 49 | 50 | componentDidMount() { 51 | const { channel } = this.props; 52 | 53 | channel.on(ADDON_EVENT_CHANGE, this.handleChangeEvent); 54 | } 55 | 56 | componentWillUnmount() { 57 | const { channel } = this.props; 58 | 59 | channel.removeListener(ADDON_EVENT_CHANGE, this.handleChangeEvent); 60 | } 61 | 62 | handleChangeEvent = ({ state: storyState }) => { 63 | this.setState({ storyState }); 64 | }; 65 | 66 | handleResetClick = () => { 67 | const { channel } = this.props; 68 | 69 | channel.emit(ADDON_EVENT_RESET); 70 | }; 71 | 72 | render() { 73 | const { storyState } = this.state; 74 | const { active } = this.props; 75 | 76 | if (active === false || storyState === null) { 77 | return null; 78 | } 79 | 80 | return ( 81 |
82 | 83 | 84 |
85 | ); 86 | } 87 | } 88 | 89 | export function register() { 90 | addons.register(ADDON_ID, (api) => { 91 | const channel = addons.getChannel(); 92 | 93 | addons.addPanel(ADDON_PANEL_ID, { 94 | title: 'State', 95 | render: ({ active, key }) => , 96 | }); 97 | }); 98 | } 99 | --------------------------------------------------------------------------------