├── .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 | 
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 |
--------------------------------------------------------------------------------