├── .babelrc ├── .github └── workflows │ └── npmpublish.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .scripts ├── npm-postpublish.js └── ver.js ├── .storybook ├── addons.js ├── config.js ├── stories.js └── webpack.config.js ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── dev ├── config.js ├── register.js └── withAdk.js ├── jest.config.js ├── jsconfig.json ├── nodemon.json ├── package-lock.json ├── package.json ├── register.js ├── src ├── ChannelStore.js ├── Layout.js ├── __tests__ │ ├── ChannelStore.test.js │ ├── __mocks__ │ │ └── storybook-addons.js │ └── config.test.js ├── config.js ├── decorator.js ├── index.d.ts ├── index.js ├── register.js └── withChannel.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ], 11 | "@babel/preset-react" 12 | ], 13 | "plugins": [ 14 | "@babel/plugin-proposal-class-properties" 15 | ] 16 | } -------------------------------------------------------------------------------- /.github/workflows/npmpublish.yml: -------------------------------------------------------------------------------- 1 | name: npm_publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | - name: Cache node modules 16 | uses: actions/cache@v1 17 | with: 18 | path: node_modules 19 | key: dependencies 20 | - run: yarn 21 | - run: yarn test 22 | - run: yarn prepare 23 | 24 | publish-npm: 25 | needs: build 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v1 29 | - uses: actions/setup-node@v1 30 | with: 31 | node-version: 12 32 | registry-url: https://registry.npmjs.org/ 33 | - name: Cache node modules 34 | uses: actions/cache@v1 35 | with: 36 | path: node_modules 37 | key: dependencies 38 | - run: yarn 39 | - run: npm publish 40 | env: 41 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 42 | 43 | # publish-gpr: 44 | # needs: build 45 | # runs-on: ubuntu-latest 46 | # steps: 47 | # - uses: actions/checkout@v1 48 | # - uses: actions/setup-node@v1 49 | # with: 50 | # node-version: 12 51 | # registry-url: https://npm.pkg.github.com/ 52 | # scope: '@your-github-username' 53 | # - run: npm ci 54 | # - run: npm publish 55 | # env: 56 | # NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | dev-dist/ 26 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | develop 3 | dev 4 | setup 5 | public 6 | src 7 | doc 8 | .babelrc 9 | .eslintrc 10 | .scripts 11 | .storybook 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.scripts/npm-postpublish.js: -------------------------------------------------------------------------------- 1 | var shell = require('shelljs'); 2 | var chalk = require('chalk'); 3 | const packageJson = require('../package.json'); 4 | 5 | shell.echo(chalk.grey(`${packageJson.name}@${packageJson.version} was successfully published.`)); 6 | -------------------------------------------------------------------------------- /.scripts/ver.js: -------------------------------------------------------------------------------- 1 | const shell = require('shelljs'); 2 | const chalk = require('chalk'); 3 | const packageJson = require('../package.json'); 4 | 5 | shell.echo(chalk.bold(`${packageJson.name}@${packageJson.version}`)); 6 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '../dev/register'; 2 | import '@storybook/addon-backgrounds/register'; 3 | import '@storybook/addon-actions/register'; 4 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies, import/no-unresolved, import/extensions */ 2 | 3 | import { configure } from '@storybook/react'; 4 | 5 | function loadStories() { 6 | require('./stories'); 7 | } 8 | 9 | configure(loadStories, module); 10 | -------------------------------------------------------------------------------- /.storybook/stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { storiesOf, addDecorator, addParameters } from '@storybook/react'; 4 | import addons, { makeDecorator } from '@storybook/addons'; 5 | 6 | import { withAdk, adkParams } from '../dev/withAdk'; 7 | 8 | /** 9 | * todo: 10 | * 1. Add themes via one global method from `config.js` with global decorator inside 11 | * 2. Set additional theme via addParameters (global/local) 12 | * 3. Select current theme via parameters (to override) 13 | * 4. Keep current theme in url 14 | * 15 | */ 16 | 17 | addParameters( 18 | adkParams({ 19 | themes: ['theme1-aa', 'theme2-bb', 't3', 't4', 't5'], 20 | currentTheme: 0, 21 | }) 22 | ); 23 | 24 | storiesOf('Storybook Addon Development Kit', module) 25 | .addDecorator(withAdk({ mainColor: 'green' })) 26 | .add( 27 | 'Stories', 28 | storyArgs => { 29 | return ( 30 |
31 | 32 |
33 | ); 34 | } 35 | // adkParams({ currentTheme: 1 }) 36 | ) 37 | .add( 38 | 'Stories2', 39 | () => { 40 | return ( 41 |
42 | 43 |
44 | ); 45 | } 46 | // adkParams({ currentTheme: 0 }) 47 | ); 48 | // .add('Details', () => ( 49 | //
50 | // 51 | //
52 | // )); 53 | 54 | // storiesOf('Test/Info', module) 55 | // .add('Stories', () => ( 56 | //
57 | // 58 | //
59 | // )) 60 | // .add('Details', () => ( 61 | //
62 | // 63 | //
64 | // )); 65 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | module: { 5 | rules: [ 6 | { 7 | test: /\.m?js$/, 8 | exclude: /(node_modules|bower_components)/, 9 | use: { 10 | loader: 'babel-loader', 11 | options: { 12 | presets: ['@babel/preset-env'], 13 | }, 14 | }, 15 | }, 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "editorGroup.border": "#bf82c6", 4 | "panel.border": "#bf82c6", 5 | "sideBar.border": "#bf82c6", 6 | "statusBar.background": "#bf82c6", 7 | "statusBar.foreground": "#15202b", 8 | "statusBarItem.hoverBackground": "#ad5fb6", 9 | "titleBar.activeBackground": "#bf82c6", 10 | "titleBar.activeForeground": "#15202b", 11 | "titleBar.inactiveBackground": "#bf82c699", 12 | "titleBar.inactiveForeground": "#15202b99", 13 | "sash.hoverBorder": "#bf82c6", 14 | "statusBar.border": "#bf82c6", 15 | "statusBar.debuggingBackground": "#89c682", 16 | "statusBar.debuggingBorder": "#89c682", 17 | "statusBar.debuggingForeground": "#15202b", 18 | "statusBarItem.remoteBackground": "#bf82c6", 19 | "statusBarItem.remoteForeground": "#15202b", 20 | "titleBar.border": "#bf82c6" 21 | }, 22 | "peacock.color": "#bf82c6" 23 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at welcome@sm-artlight.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 React Theming 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/%40storybook%2Faddon-devkit.svg)](https://badge.fury.io/js/%40storybook%2Faddon-devkit) 2 | ![npm](https://img.shields.io/npm/dt/@storybook/addon-devkit) 3 | 4 | # Storybook ADK 5 | 6 | > Some of features originally introduced in this package are already available via Storybook API. Please consider https://github.com/storybookjs/addon-kit first, which is a simple Github repo template that uses the latest addon APIs. 7 | 8 | This kit provides additional middleware for Storybook API and can be used for creating addons based on this. 9 | 10 | Simplifies the addons creation. Keeps in sync addon's data through the channel. Provides intelligent blocks for creating addon UI. Offer simple API for registering addons and creating decorators. It's a base to quickly build your custom brand new awesome addon 11 | 12 | ## Features 13 | 14 | - Hides under the hood all the complex issues of communication through the channel and data synchronization while switching stories. 15 | - Connects your addon components to your addon store via HOCs and updates it only when data changes 16 | - Divides addon store data to global and local. Tracks story surfing in order to switch appropriate local data both on manager and preview sides simultaneously 17 | - Keeps immutable init data and overridable data which you mutate via actions 18 | - Provides redux like approach to deal with your addon store via selectors and actions (but don't worry, the default action just simply override your data) 19 | - Allows to connect any amount of pannels, buttons and any other addon types to the single addon store 20 | - Offers UI container which automatically reflects the aspect ratio of addon panel. Extremely useful to create addon UI responsive for vertical and horizontal panel positions 21 | - Includes Typescript definitions 22 | 23 | ## Usage 24 | 25 | ```shell 26 | npm i --save @storybook/addon-devkit 27 | ``` 28 | 29 | ```js 30 | import { 31 | register, 32 | createDecorator, 33 | setParameters, 34 | setConfig, 35 | Layout, 36 | Block, 37 | } from '@storybook/addon-devkit' 38 | 39 | ``` 40 | 41 | ## API 42 | 43 | ### Register manager side Addon panel 44 | 45 | HOC to register addon UI and connect it to the addon store. 46 | 47 | ```js 48 | // in your addon `register.js` 49 | import { register } from '@storybook/addon-devkit' 50 | 51 | register( 52 | { 53 | ...selectors, 54 | }, 55 | ({ global, local }) => ({ 56 | ...globalActions, 57 | ...localActions, 58 | }) 59 | )(AddonPanelUI); 60 | 61 | 62 | ``` 63 | 64 | where `selectors` is an object with functions like: 65 | 66 | ```js 67 | { 68 | deepData: store => store.path.to.deep.store.data, 69 | } 70 | 71 | ``` 72 | 73 | and `actions` could be "global" and "local". Global actions affects on the global part of store, while local only on the data related to the current story. 74 | 75 | ```js 76 | 77 | ({ global, local }) => ({ 78 | // action to manipulate with common data 79 | increase: global(store => ({ 80 | ...store, 81 | index: store.index + 1, 82 | })), 83 | // action to manipulate with current story data 84 | // usage: setBackground('#ff66cc') 85 | setBackground: local((store, color) => ({ 86 | ...store, 87 | backgroundColor: color, 88 | })), 89 | // action to override data 90 | // usage: update({...newData}) 91 | update: global(), 92 | }) 93 | 94 | ``` 95 | 96 | AddonPanelUI - is your component which appears on addon panel when you select appropriate tab 97 | > Note: the HOC automatically track the `active` state of addon and shows it only when it's necessary 98 | 99 | register HOC will pass the follow props to the `AddonPanelUI` component: 100 | 101 | ```js 102 | 115 | 116 | ``` 117 | 118 | As soon as you change the store via actions both the `AddonPanelUI` and `storyDecorator` will be re-rendered with the new data. 119 | 120 | Same if the data will come from the story - it will be updated 121 | 122 | After initialization HOC will wait for init data from story and only after it will render UI 123 | 124 | 125 | ### Create stories side decorator 126 | 127 | HOC to create decorator and connect it to the addon store. 128 | 129 | ```js 130 | // in your addon `decorator.js` 131 | import { createDecorator } from '@storybook/addon-devkit' 132 | 133 | export const withMyAddon = createDecorator({ 134 | ...selectors, 135 | }, 136 | ({ global, local }) => ({ 137 | ...globalActions, 138 | ...localActions, 139 | }) 140 | )(DecoratorUI, { isGlobal }); 141 | 142 | ``` 143 | 144 | so then you can use your decorator this way: 145 | 146 | ```js 147 | // stories.js 148 | 149 | import React from 'react'; 150 | import { storiesOf, addDecorator, addParameters } from '@storybook/react'; 151 | import { withMyAddon, myAddonParams } from 'my-addon'; 152 | 153 | // add decorator globally 154 | addDecorator(withMyAddon({ ...initData })) 155 | addParameters(myAddonParams({ ...globalParams })) 156 | 157 | storiesOf('My UI Kit', module) 158 | // ...or add decorator locally 159 | .addDecorator(withMyAddon({ ...initData })) 160 | .add( 161 | 'Awesome', 162 | () => , 163 | myAddonParams({ ...localParams }) 164 | ) 165 | 166 | ``` 167 | 168 | `DecoratorUI` could look like this: 169 | 170 | ```js 171 | 172 | const DecoratorUI = ({ context, getStory, selectedData }) => ( 173 |
174 |

Title: {selectedData}

175 | {getStory(context)} 176 |
177 | ); 178 | ``` 179 | 180 | When `isGlobal = true` decorator will consider all passing data as global 181 | 182 | >Note: addon parameters will be merged with init data and available both for decorator and panel selectors 183 | 184 | 185 | ### Pass parameters to addon 186 | 187 | Creates functions for passing parameters to your addon 188 | 189 | See usage above 190 | 191 | ```js 192 | import { setParameters } from '@storybook/addon-devkit' 193 | 194 | export const myAddonParams = setParameters() 195 | 196 | ``` 197 | 198 | ### Addon config 199 | 200 | In order to create addon you need to specify some unique parameters like event name, addon title, parameters key and others. They should be same on manager and preview sides. If you don't specify them addon-devkit will use the default ones. 201 | To specify your own use `setConfig`: 202 | 203 | ```js 204 | import { setConfig } from '@storybook/addon-devkit'; 205 | 206 | setConfig({ 207 | addId: 'dev_adk', 208 | panelTitle: 'ADK DEV' 209 | }); 210 | 211 | ``` 212 | You should run it **before** using `register`, `setParameters` and `createDecorator` 213 | 214 | >Note: don't forget to use setConfig both in on manager and preview sides with the same parameters 215 | 216 | 217 | ### Addon panel UI components 218 | 219 | Components to organize UI in a row when panel in bottom position and in column when it on the right side 220 | 221 | ```js 222 | import { Layout, Block, register } from '@storybook/addon-devkit'; 223 | import { styled } from '@storybook/theming'; 224 | import './config' 225 | 226 | const LayoutBlock = styled(Layout)` 227 | ...styles 228 | ` 229 | 230 | const AddonBlock = styled(Block)` 231 | ...styles 232 | ` 233 | 234 | const AddonPanel = () => ( 235 | 236 | 237 | {UI1} 238 | 239 | 240 | {UI2} 241 | 242 | 243 | {UI3} 244 | 245 | 246 | ) 247 | 248 | register()(AddonPanel) 249 | 250 | ``` 251 | 252 | `` has `display: flex` with `flex-direction: row` when bottom and `flex-direction: column` in right side. 253 | 254 | You can specify the size of ``. In case of horizontal layout it will be the width, in case of vertical - height of element. 255 | 256 | Otherwise it will have `flex-grow: 1` 257 | 258 | ## Credits 259 | 260 |
Created with ❤︎ to React and Storybook by Oleg Proskurin [React Theming] 261 |
262 | -------------------------------------------------------------------------------- /dev/config.js: -------------------------------------------------------------------------------- 1 | import { setConfig } from '../src/config'; 2 | 3 | setConfig({ 4 | addonId: 'dev_adk', 5 | panelTitle: 'ADK DEV' 6 | }); 7 | -------------------------------------------------------------------------------- /dev/register.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { styled } from '@storybook/theming'; 3 | import { register } from '../src/register'; 4 | import { Layout, Block } from '../src/Layout'; 5 | import './config'; 6 | 7 | 8 | const LayoutBlock = styled(Layout)` 9 | padding: 0px; 10 | border: red 1px solid; 11 | label: layout-with-styles; 12 | ` 13 | 14 | const AddonBlock = styled(Block)` 15 | margin: 2px; 16 | padding: 4px; 17 | border: 2px solid gray; 18 | font-size: 14px; 19 | background-color: pink; 20 | label: block-with-styles; 21 | ` 22 | 23 | const AddonPanel = ({ 24 | api, 25 | kind, 26 | indInc, 27 | indDec, 28 | update, 29 | theme, 30 | data, 31 | comment, 32 | request 33 | }) => { 34 | return ( 35 | 36 | 37 | kind: {kind} 38 |
39 | 40 | 41 |
42 | 45 | 46 | 47 |
48 | {/* 49 | {JSON.stringify(api.getCurrentStoryData())} 50 | */} 51 | 52 | channel store data:
({JSON.stringify(data)}) 53 |
54 | {/* data ({JSON.stringify(rect, null, 2)}) */} 55 |
56 | ); 57 | }; 58 | 59 | const AsyncRequest = () => new Promise(resolve => { 60 | setTimeout(() => resolve(), 3000); 61 | }) 62 | 63 | register( 64 | { 65 | themeInd: store => store.currentTheme || 0, 66 | themeList: store => store.themes, 67 | theme: store => store.themes[store.currentTheme], 68 | data: store => store 69 | }, 70 | ({ global, local }) => ({ 71 | indInc: global(store => ({ 72 | // ...store, 73 | currentTheme: (store.currentTheme || 0) + 1, 74 | })), 75 | indDec: global(store => ({ 76 | // ...store, 77 | currentTheme: (store.currentTheme || 0) - 1, 78 | })), 79 | update: global(), 80 | comment: local(store => ({ 81 | ...store, 82 | comment: 'comment', 83 | })), 84 | request: local(async store => { 85 | await AsyncRequest(); 86 | return ({ 87 | ...store, 88 | result: 'Request Success', 89 | }) 90 | }), 91 | }) 92 | )(AddonPanel); 93 | 94 | /* Plain object example 95 | 96 | register({ 97 | nextInd: (store) => ({ ...store, currentTheme: store.currentTheme + 1 }), 98 | update: null, 99 | })(AddonPanel); 100 | 101 | */ 102 | -------------------------------------------------------------------------------- /dev/withAdk.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createDecorator, setParameters } from '../src/decorator'; 3 | import './config'; 4 | 5 | const DecoratorUI = ({ context, getStory, theme, info }) => ( 6 |
7 | Theme: {theme}
8 | Data: {info}
9 | {getStory({ customAddonArgs: { result: 'foo' } })} 10 |
11 | ); 12 | 13 | export const withAdk = createDecorator( 14 | { 15 | theme: store => store.themes[store.currentTheme], 16 | info: store => JSON.stringify(store, null, 2), 17 | }, 18 | {}, 19 | { 20 | themeWithFn: (params, { theme }) => ({ fn: () => theme }), 21 | } 22 | )(DecoratorUI, { isGlobal: true }); 23 | export const adkParams = setParameters(); 24 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | collectCoverage: true, 4 | collectCoverageFrom: ['src/**/*.js', '!**/node_modules/**'], 5 | coverageReporters: ['html', 'text'], 6 | moduleNameMapper: { 7 | '@storybook/addons': '/src/__tests__/__mocks__/storybook-addons.js' 8 | }, 9 | testPathIgnorePatterns: ['/node_modules/', '/__mocks__/'], 10 | coverageThreshold: { 11 | global: { 12 | branches: 0, 13 | functions: 0, 14 | lines: 0, 15 | statements: 0, 16 | }, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "typeAcquisition": { 3 | "include": [ 4 | "jest" 5 | ] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "./src/", 4 | "./dev/", 5 | "./.storybook" 6 | ], 7 | "ext": "js", 8 | "ignore": [ 9 | "__tests__/**/" 10 | ] 11 | 12 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@storybook/addon-devkit", 3 | "version": "1.4.2", 4 | "description": "Storybook Addon Development Kit", 5 | "author": { 6 | "name": "Oleg Proskurin", 7 | "url": "https://github.com/UsulPro" 8 | }, 9 | "main": "dist/index.js", 10 | "types": "dist/index.d.ts", 11 | "scripts": { 12 | "test": "jest", 13 | "tdd": "jest --watch", 14 | "prepare-storybook": "yarn prepare-dev && yarn prepare && start-storybook -p 9001 --ci", 15 | "start-storybook": "start-storybook -p 9001 --ci", 16 | "start": "nodemon --exec yarn start-storybook", 17 | "build-storybook": "build-storybook -s public", 18 | "dev": "nodemon --exec yarn prepare", 19 | "prepare-dev": "babel dev --out-dir dev-dist --verbose", 20 | "prepare": "package-prepare && cp src/index.d.ts dist", 21 | "postpublish": "node .scripts/npm-postpublish.js" 22 | }, 23 | "dependencies": { 24 | "@reach/rect": "^0.2.1", 25 | "@storybook/addons": "^6.1.18", 26 | "@storybook/core-events": "^6.1.18", 27 | "@storybook/theming": "^6.1.18", 28 | "deep-equal": "^2.0.2", 29 | "prop-types": "^15.6.2" 30 | }, 31 | "devDependencies": { 32 | "@babel/cli": "^7.12.17", 33 | "@babel/core": "^7.12.17", 34 | "@babel/plugin-proposal-class-properties": "^7.12.13", 35 | "@babel/preset-env": "^7.12.17", 36 | "@babel/preset-react": "^7.12.13", 37 | "@storybook/addon-actions": "^6.1.18", 38 | "@storybook/addon-backgrounds": "^6.1.18", 39 | "@storybook/addon-links": "^6.1.18", 40 | "@storybook/react": "^6.1.18", 41 | "@types/react": "^16.9.19", 42 | "@usulpro/package-prepare": "^1.1.4", 43 | "babel-eslint": "^10.0.1", 44 | "babel-jest": "^24.9.0", 45 | "babel-loader": "^8.0.2", 46 | "nodemon": "^1.18.9", 47 | "prettier": "^1.18.2", 48 | "react": "16.8.6", 49 | "react-dom": "16.8.6", 50 | "react-scripts": "3.0.0" 51 | }, 52 | "eslintConfig": { 53 | "extends": "react-app" 54 | }, 55 | "browserslist": [ 56 | ">0.2%", 57 | "not dead", 58 | "not ie <= 11", 59 | "not op_mini all" 60 | ], 61 | "repository": { 62 | "type": "git", 63 | "url": "https://github.com/storybookjs/addon-development-kit" 64 | }, 65 | "keywords": [ 66 | "storybook", 67 | "react", 68 | "addon", 69 | "decorator", 70 | "customization", 71 | "boilerplate", 72 | "npm", 73 | "development", 74 | "addons", 75 | "storybook-addon", 76 | "appearance" 77 | ], 78 | "license": "MIT", 79 | "bugs": { 80 | "url": "https://github.com/storybookjs/addon-development-kit/issues" 81 | }, 82 | "homepage": "https://github.com/storybookjs/addon-development-kit", 83 | "peerDependencies": { 84 | "@storybook/addons": "*", 85 | "@storybook/react": "*", 86 | "react": "*", 87 | "react-dom": "*" 88 | }, 89 | "storybook": { 90 | "displayName": "Addon devkit" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /register.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.register = void 0; 7 | 8 | var _react = _interopRequireDefault(require("react")); 9 | 10 | var _addons = _interopRequireWildcard(require("@storybook/addons")); 11 | 12 | var _coreEvents = require("@storybook/core-events"); 13 | 14 | var _rect = _interopRequireDefault(require("@reach/rect")); 15 | 16 | var _config = require("./dist/config"); 17 | 18 | var _withChannel = _interopRequireDefault(require("./dist/withChannel")); 19 | 20 | var _Layout = require("./dist/Layout"); 21 | 22 | function _getRequireWildcardCache() { if (typeof WeakMap !== "function") return null; var cache = new WeakMap(); _getRequireWildcardCache = function () { return cache; }; return cache; } 23 | 24 | function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } var cache = _getRequireWildcardCache(); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; if (obj != null) { var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } 25 | 26 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 27 | 28 | function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } 29 | 30 | // todo: remove 31 | const panelDimesions = rect => rect ? { 32 | width: rect.width, 33 | height: rect.height, 34 | isLandscape: rect.width >= rect.height 35 | } : {}; 36 | 37 | const addonLayout = isLandscape => { 38 | const Layout = ({ 39 | style, 40 | children, 41 | ...props 42 | }) => _react.default.createElement("div", _extends({ 43 | name: "addon-layout", 44 | style: { 45 | display: 'flex', 46 | flexDirection: isLandscape ? 'row' : 'column', 47 | justifyContent: 'space-between', 48 | alignItems: 'stretch', 49 | height: '100%', 50 | ...style 51 | } 52 | }, props), children); 53 | 54 | return Layout; 55 | }; 56 | 57 | const addonBlock = isLandscape => { 58 | const Block = ({ 59 | style, 60 | children, 61 | size, 62 | ...props 63 | }) => _react.default.createElement("div", _extends({ 64 | name: "addon-block", 65 | style: { 66 | flexGrow: 1, 67 | ...(size ? { ...(isLandscape ? { 68 | width: size 69 | } : { 70 | height: size 71 | }), 72 | flexGrow: undefined 73 | } : { ...(isLandscape ? { 74 | width: 2 75 | } : { 76 | height: 2 77 | }) 78 | }), 79 | ...style 80 | } 81 | }, props), children); 82 | 83 | return Block; 84 | }; 85 | 86 | class PanelHOC extends _react.default.Component { 87 | constructor(props) { 88 | super(props); 89 | const urlState = props.api.getUrlState(); 90 | this.state = { ...urlState 91 | }; 92 | props.api.on(_coreEvents.STORY_CHANGED, (kind, story) => this.setState({ 93 | kind, 94 | story 95 | })); 96 | } 97 | 98 | render() { 99 | const Panel = this.props.component; 100 | const { 101 | api, 102 | active, 103 | data, 104 | setData, 105 | config, 106 | isFirstDataReceived 107 | } = this.props; 108 | const { 109 | ADDON_ID, 110 | PANEL_ID, 111 | PANEL_Title 112 | } = config; 113 | const { 114 | kind, 115 | story 116 | } = this.state; 117 | if (!active) return null; 118 | return _react.default.createElement(_rect.default, null, ({ 119 | rect, 120 | ref 121 | }) => { 122 | const dim = panelDimesions(rect); 123 | const Layout = addonLayout(dim.isLandscape); 124 | const Block = addonBlock(dim.isLandscape); 125 | return _react.default.createElement("div", { 126 | ref: ref, 127 | name: "addon-holder", 128 | style: { 129 | height: '100%' 130 | } 131 | }, _react.default.createElement(_Layout.LayoutProvider, null, _react.default.createElement(Panel, _extends({}, this.props.actions, this.props.selectors, { 132 | api: api, 133 | active: active, 134 | store: data, 135 | setData: setData, 136 | kind: kind, 137 | story: story, 138 | ADDON_ID: ADDON_ID, 139 | PANEL_ID: PANEL_ID, 140 | PANEL_Title: PANEL_Title, 141 | rect: dim, 142 | Layout: Layout, 143 | Block: Block, 144 | isFirstDataReceived: isFirstDataReceived 145 | })))); 146 | }); 147 | } 148 | 149 | } 150 | 151 | const register = (storeSelectors, createActions) => (Panel, { 152 | type = _addons.types.PANEL, 153 | initData 154 | } = {}) => { 155 | const config = (0, _config.getConfig)(); 156 | const { 157 | EVENT_ID_INIT, 158 | EVENT_ID_DATA, 159 | EVENT_ID_BACK, 160 | ADDON_ID, 161 | PANEL_Title, 162 | PANEL_ID 163 | } = config; 164 | const WithChannel = (0, _withChannel.default)({ 165 | EVENT_ID_INIT, 166 | EVENT_ID_DATA, 167 | EVENT_ID_BACK, 168 | ADDON_ID, 169 | initData, 170 | panel: true, 171 | storeSelectors, 172 | createActions 173 | })(PanelHOC); 174 | 175 | _addons.default.register(ADDON_ID, api => { 176 | _addons.default.add(PANEL_ID, { 177 | title: PANEL_Title, 178 | type, 179 | id: PANEL_ID, 180 | render: ({ 181 | active, 182 | key 183 | } = {}) => _react.default.createElement(WithChannel, { 184 | key: key, 185 | api: api, 186 | active: active, 187 | component: Panel, 188 | config: config 189 | }) 190 | }); 191 | }); 192 | }; 193 | 194 | exports.register = register; -------------------------------------------------------------------------------- /src/ChannelStore.js: -------------------------------------------------------------------------------- 1 | import addons from '@storybook/addons'; 2 | import deepEqual from 'deep-equal'; 3 | 4 | const GLOBAL = 'global'; 5 | 6 | export default class ChannelStore { 7 | constructor({ 8 | EVENT_ID_INIT, 9 | EVENT_ID_DATA, 10 | EVENT_ID_BACK, 11 | 12 | name = 'store', 13 | initData = {}, 14 | isPanel = false, 15 | storyId, 16 | }) { 17 | this.EVENT_ID_INIT = EVENT_ID_INIT; 18 | this.EVENT_ID_DATA = EVENT_ID_DATA; 19 | this.EVENT_ID_BACK = EVENT_ID_BACK; 20 | this.name = name; 21 | this.initData = initData; 22 | this.isPanel = isPanel; 23 | this.id = storyId; 24 | 25 | this.store = { 26 | [GLOBAL]: { init: this.initData || {}, over: {} }, 27 | }; 28 | } 29 | 30 | selectorId = null; 31 | 32 | subscriber = () => {}; 33 | onConnectedFn = () => {}; 34 | 35 | channel = addons.getChannel(); 36 | 37 | connect = () => { 38 | if (this.isPanel) { 39 | this.channel.on(this.EVENT_ID_INIT, this.onInitChannel); 40 | this.channel.on(this.EVENT_ID_DATA, this.onDataChannel); 41 | } else { 42 | this.channel.on(this.EVENT_ID_BACK, this.onDataChannel); 43 | } 44 | this.onConnectedFn(); 45 | }; 46 | 47 | emit = data => 48 | this.channel.emit(this.isPanel ? this.EVENT_ID_BACK : this.EVENT_ID_DATA, { 49 | data, 50 | id: this.id, 51 | }); 52 | 53 | init = data => this.channel.emit(this.EVENT_ID_INIT, { data, id: this.id }); 54 | 55 | removeInit = () => 56 | this.channel.removeListener(this.EVENT_ID_INIT, this.onInitChannel); 57 | 58 | removeData = () => 59 | this.channel.removeListener( 60 | this.isPanel ? this.EVENT_ID_DATA : this.EVENT_ID_BACK, 61 | this.onDataChannel 62 | ); 63 | 64 | onInitChannel = initData => { 65 | const { data, id } = initData; 66 | const selectorId = id || GLOBAL; 67 | const selectedData = { ...(this.store[selectorId] || {}) }; 68 | /** 69 | * Previous behavior didn't reset state on init event 70 | * it caused that we didn't see changes after 71 | * updating story parameters 72 | * So i'm removing this, but if we need to make it optional 73 | * this is how to revert it: 74 | * selectedData.over = selectedData.over || {}; 75 | * 76 | * Update: 77 | * Now we check if coming initial data the same as we already have in the store 78 | * this allow us to not reset changes while switching stories 79 | * 80 | * it works if stories don't contain parameters or changing data any other way 81 | * 82 | * Additional it's better if actions don't return whole store 83 | * compare: 84 | * 85 | * // right way: 86 | * store => ({ 87 | * currentTheme: store.currentTheme + 1, 88 | * }) 89 | * 90 | * vs 91 | * 92 | * // wrong way: 93 | * store => ({ 94 | * ...store, // this cause an overriding of whole store 95 | * currentTheme: store.currentTheme + 1, 96 | * }) 97 | * 98 | * the better solution would be to granularly commit updates and store only changed values 99 | * 100 | */ 101 | if (deepEqual(selectedData.init, data)) { 102 | selectedData.over = selectedData.over || {}; 103 | } else { 104 | selectedData.init = data; 105 | selectedData.over = {}; 106 | } 107 | 108 | this.store[selectorId] = selectedData; 109 | this.selectorId = selectorId; 110 | this.subscriber(); 111 | this.send(); 112 | }; 113 | 114 | onDataChannel = updData => { 115 | const { data, id } = updData; 116 | if (this.isPanel) { 117 | const selectorId = id || GLOBAL; 118 | const selectedData = this.store[selectorId]; 119 | selectedData.over = data; 120 | this.selectorId = selectorId; 121 | } else { 122 | this.store = data; 123 | } 124 | 125 | this.subscriber(); 126 | }; 127 | 128 | selectData = () => { 129 | const id = this.isPanel ? this.selectorId : this.id; 130 | 131 | const { global = {} } = this.store; 132 | const local = this.store[id] || {}; 133 | 134 | const finalData = { 135 | ...global.init, 136 | ...local.init, 137 | ...global.over, 138 | ...local.over, 139 | }; 140 | 141 | return finalData; 142 | }; 143 | 144 | onData = subscriberFn => { 145 | this.subscriber = () => { 146 | const data = this.selectData(); 147 | subscriberFn(data); 148 | }; 149 | }; 150 | 151 | onConnected = onConnectedFn => { 152 | this.onConnectedFn = onConnectedFn; 153 | }; 154 | 155 | send = () => { 156 | this.emit(this.store); 157 | }; 158 | 159 | defaultReducer = (store, payload) => ({ 160 | ...store, 161 | ...payload, 162 | }); 163 | 164 | _createAction = (reducer, getSubId) => { 165 | return async payload => { 166 | const subId = getSubId(); 167 | const subData = this.store[subId]; 168 | const current = { 169 | ...subData.init, 170 | ...subData.over, 171 | }; 172 | const over = await (reducer || this.defaultReducer)(current, payload); 173 | subData.over = over; 174 | 175 | this.send(); 176 | this.subscriber(); 177 | }; 178 | }; 179 | 180 | createGlobalAction = reducer => this._createAction(reducer, () => GLOBAL); 181 | 182 | createLocalAction = reducer => 183 | this._createAction(reducer, () => this.selectorId || this.id); 184 | 185 | sendInit = data => { 186 | this.init(data); 187 | }; 188 | 189 | disconnect = () => { 190 | this.removeInit(); 191 | this.removeData(); 192 | }; 193 | } 194 | 195 | let singleStore; 196 | 197 | export const getSingleStore = (...args) => { 198 | singleStore = singleStore || new ChannelStore(...args); 199 | return singleStore; 200 | }; 201 | 202 | export const getNewStore = (...args) => { 203 | return new ChannelStore(...args); 204 | }; 205 | -------------------------------------------------------------------------------- /src/Layout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Rect from '@reach/rect'; 3 | import { styled, cx, css } from '@storybook/theming'; 4 | 5 | const layout = React.createContext({}); 6 | 7 | const panelDimensions = rect => 8 | rect 9 | ? { 10 | width: rect.width, 11 | height: rect.height, 12 | isLandscape: rect.width >= rect.height, 13 | } 14 | : {}; 15 | 16 | const AddonHolder = styled('div')` 17 | height: 100%; 18 | label: addon-holder; 19 | `; 20 | 21 | export const LayoutProvider = ({ children }) => ( 22 | 23 | {({ rect, ref }) => { 24 | const dimensions = panelDimensions(rect); 25 | return ( 26 | 27 | {children} 28 | 29 | ); 30 | }} 31 | 32 | ); 33 | 34 | 35 | const StyledOverridden = ({ 36 | className, 37 | overrides, 38 | children, 39 | isLandscape, 40 | size, 41 | ...props 42 | }) => ( 43 |
44 | {children} 45 |
46 | ); 47 | 48 | const StyledLayout = styled(StyledOverridden)` 49 | display: flex; 50 | flex-direction: ${({ isLandscape }) => (isLandscape ? 'row' : 'column')}; 51 | justify-content: space-between; 52 | align-items: stretch; 53 | height: 100%; 54 | label: addon-layout; 55 | `; 56 | 57 | export const Layout = ({ className, children }) => ( 58 | 59 | {({ isLandscape }) => ( 60 | {children} 61 | )} 62 | 63 | ); 64 | 65 | 66 | const px = v => `${v}px`; 67 | 68 | const StyledBlock = styled(StyledOverridden)` 69 | ${({ isLandscape }) => (isLandscape ? 'width' : 'height')}: ${({ size }) => 70 | size ? px(size) : '2px'}; 71 | ${({ size }) => (size ? '' : 'flex-grow: 1;')} 72 | label: addon-block; 73 | `; 74 | 75 | export const Block = ({ size, children, className }) => ( 76 | 77 | {({ isLandscape }) => ( 78 | 79 | {children} 80 | 81 | )} 82 | 83 | ); 84 | -------------------------------------------------------------------------------- /src/__tests__/ChannelStore.test.js: -------------------------------------------------------------------------------- 1 | import ChannelStore, { getSingleStore } from '../ChannelStore'; 2 | import { getConfig } from '../config'; 3 | 4 | jest.mock('@storybook/addons', () => { 5 | const mockInfo = { 6 | onEvent: jest.fn(), 7 | removeEvent: jest.fn(), 8 | emit: jest.fn(console.log), 9 | reset() { 10 | this.onEvent.mockReset(); 11 | this.removeEvent.mockReset(); 12 | this.emit.mockReset(); 13 | }, 14 | }; 15 | const channel = { 16 | on: (event, cb) => mockInfo.onEvent([event, cb]), 17 | removeListener: (event, cb) => mockInfo.removeEvent([event, cb]), 18 | emit: (event, data) => mockInfo.emit([event, data]), 19 | mock: () => mockInfo, 20 | }; 21 | return { 22 | getChannel: () => channel, 23 | }; 24 | }); 25 | 26 | const config = getConfig(); 27 | const configWith = props => ({ ...config, ...props }); 28 | const whatSide = isPanel => (isPanel ? 'Panel-Side' : 'Decorator-Side'); 29 | 30 | describe.each([{ isPanel: false }, { isPanel: true }])( 31 | 'ChannelStore %o', 32 | ({ isPanel }) => { 33 | it(`should init ${whatSide(isPanel)} Store by default`, () => { 34 | const store = new ChannelStore(configWith({ isPanel })); 35 | store.channel = null; // exclude mocked channel from snapshot 36 | expect(store).toMatchInlineSnapshot( 37 | ` 38 | ChannelStore { 39 | "EVENT_ID_BACK": "adk/event/back", 40 | "EVENT_ID_DATA": "adk/event/data", 41 | "EVENT_ID_INIT": "adk/event/init", 42 | "_createAction": [Function], 43 | "channel": null, 44 | "connect": [Function], 45 | "createGlobalAction": [Function], 46 | "createLocalAction": [Function], 47 | "defaultReducer": [Function], 48 | "disconnect": [Function], 49 | "emit": [Function], 50 | "id": undefined, 51 | "init": [Function], 52 | "initData": Object {}, 53 | "isPanel": ${isPanel}, 54 | "name": "store", 55 | "onConnected": [Function], 56 | "onConnectedFn": [Function], 57 | "onData": [Function], 58 | "onDataChannel": [Function], 59 | "onInitChannel": [Function], 60 | "removeData": [Function], 61 | "removeInit": [Function], 62 | "selectData": [Function], 63 | "selectorId": null, 64 | "send": [Function], 65 | "sendInit": [Function], 66 | "store": Object { 67 | "global": Object { 68 | "init": Object {}, 69 | "over": Object {}, 70 | }, 71 | }, 72 | "subscriber": [Function], 73 | } 74 | ` 75 | ); 76 | }); 77 | 78 | it(`should init ${whatSide(isPanel)} Store`, () => { 79 | const store = new ChannelStore(configWith({ isPanel })); 80 | expect(store).toHaveProperty('isPanel', isPanel); 81 | }); 82 | 83 | it(`should connect to channel from ${whatSide(isPanel)}`, () => { 84 | const store = new ChannelStore(configWith({ isPanel })); 85 | store.channel.mock().reset(); 86 | const onConnected = jest.fn(); 87 | store.onConnected(onConnected); 88 | store.connect(); 89 | expect(onConnected).toHaveBeenCalledTimes(1); 90 | expect(store.channel.mock().onEvent).toHaveBeenNthCalledWith( 91 | 1, 92 | isPanel 93 | ? [store.EVENT_ID_INIT, store.onInitChannel] 94 | : [store.EVENT_ID_BACK, store.onDataChannel] 95 | ); 96 | if (isPanel) { 97 | expect(store.channel.mock().onEvent).toHaveBeenNthCalledWith(2, [ 98 | store.EVENT_ID_DATA, 99 | store.onDataChannel, 100 | ]); 101 | } 102 | }); 103 | 104 | it(`should disconnect from channel on ${whatSide(isPanel)}`, () => { 105 | const store = new ChannelStore(configWith({ isPanel })); 106 | store.channel.mock().reset(); 107 | store.connect(); 108 | store.disconnect(); 109 | 110 | expect(store.channel.mock().removeEvent).toHaveBeenCalledTimes(2); 111 | 112 | expect(store.channel.mock().removeEvent).toHaveBeenNthCalledWith(1, [ 113 | store.EVENT_ID_INIT, 114 | store.onInitChannel, 115 | ]); 116 | 117 | expect(store.channel.mock().removeEvent).toHaveBeenNthCalledWith(2, [ 118 | isPanel ? store.EVENT_ID_DATA : store.EVENT_ID_BACK, 119 | store.onDataChannel, 120 | ]); 121 | }); 122 | 123 | describe.each([false, true])( 124 | 'should receive and send data to channel (isGlobal: %s)', 125 | isGlobal => { 126 | const storyId = isGlobal 127 | ? null 128 | : 'storybook-addon-development-kit--stories'; 129 | let store; 130 | beforeEach(() => { 131 | store = new ChannelStore(configWith({ isPanel, storyId })); 132 | store.channel.mock().reset(); 133 | store.connect(); 134 | }); 135 | 136 | it('should trigger on init channel message', () => { 137 | const onData = jest.fn(); 138 | store.onData(onData); 139 | const initData = { 140 | id: storyId, 141 | data: ['theme1', 'theme2', 'theme3'], 142 | }; 143 | store.onInitChannel(initData); 144 | 145 | expect(onData).toHaveBeenCalledTimes(1); 146 | 147 | expect(store.channel.mock().emit).toHaveBeenCalledTimes(1); 148 | 149 | expect(store.channel.mock().emit).toHaveBeenNthCalledWith(1, [ 150 | isPanel ? store.EVENT_ID_BACK : store.EVENT_ID_DATA, 151 | { 152 | data: { 153 | global: { init: storyId ? {} : initData.data, over: {} }, 154 | ...(storyId && { 155 | [storyId]: { 156 | init: initData.data, 157 | over: {}, 158 | }, 159 | }), 160 | }, 161 | id: storyId, 162 | }, 163 | ]); 164 | }); 165 | 166 | it('should trigger on data channel message', () => { 167 | const onData = jest.fn(); 168 | store.onData(onData); 169 | 170 | const initData = { 171 | id: storyId, 172 | data: ['theme1', 'theme2', 'theme3'], 173 | }; 174 | store.onInitChannel(initData); 175 | 176 | const newData = { 177 | id: storyId, 178 | data: ['T1', 'T2', 'T3'], 179 | }; 180 | store.onDataChannel(newData); 181 | if (isPanel) { 182 | const storeData = { 183 | init: initData.data, 184 | over: newData.data, 185 | }; 186 | expect(store.store).toEqual({ 187 | global: isGlobal 188 | ? storeData 189 | : { 190 | init: {}, 191 | over: {}, 192 | }, 193 | ...(!isGlobal && { 194 | [storyId]: storeData, 195 | }), 196 | }); 197 | } else { 198 | expect(store.store).toEqual(newData.data); 199 | } 200 | }); 201 | } 202 | ); 203 | } 204 | ); 205 | 206 | describe('ChannelStore Actions', () => { 207 | let store; 208 | const initData = { 209 | index: 0, 210 | items: [ 211 | 'apple', 212 | 'banana', 213 | 'orange', 214 | 'pear', 215 | 'cherry', 216 | 'tomato', 217 | 'cucumber', 218 | ], 219 | }; 220 | const initWith = props => ({ ...initData, ...props }); 221 | const onData = jest.fn(); 222 | 223 | beforeEach(() => { 224 | store = new ChannelStore(configWith({ isPanel: true, initData })); 225 | store.channel.mock().reset(); 226 | onData.mockReset(); 227 | store.onData(onData); 228 | store.connect(); 229 | }); 230 | 231 | const reducer = (store, step) => ({ 232 | ...store, 233 | index: store.index + step, 234 | }); 235 | 236 | it('should create global action / call subscriber / send event', async () => { 237 | const incAction = store.createGlobalAction(reducer); 238 | await incAction(2); 239 | const newData = initWith({ index: 2 }); 240 | const newStore = { 241 | global: { 242 | init: initData, 243 | over: newData, 244 | }, 245 | }; 246 | 247 | expect(store.store).toEqual(newStore); 248 | 249 | expect(onData).toHaveBeenCalledTimes(1); 250 | 251 | expect(onData).toHaveBeenNthCalledWith(1, newData); 252 | 253 | expect(store.channel.mock().emit).toHaveBeenCalledTimes(1); 254 | 255 | expect(store.channel.mock().emit).toHaveBeenNthCalledWith(1, [ 256 | store.EVENT_ID_BACK, 257 | { 258 | data: newStore, 259 | id: undefined, 260 | }, 261 | ]); 262 | }); 263 | 264 | test.todo('create action with default reducer'); 265 | test.todo('create local action'); 266 | }); 267 | 268 | describe('getSingleStore', () => { 269 | it('should create and refer to single store', () => { 270 | const panelStore = getSingleStore({ isPanel: true }); 271 | const iconStore = getSingleStore(); 272 | 273 | expect(panelStore).toBe(iconStore); 274 | }); 275 | }); 276 | -------------------------------------------------------------------------------- /src/__tests__/__mocks__/storybook-addons.js: -------------------------------------------------------------------------------- 1 | const channel = { 2 | on: (event, cb) => console.log('on', event), 3 | }; 4 | 5 | const addons = { 6 | getChannel: () => channel, 7 | }; 8 | 9 | export default addons; 10 | -------------------------------------------------------------------------------- /src/__tests__/config.test.js: -------------------------------------------------------------------------------- 1 | import { setConfig, getConfig } from '../config'; 2 | 3 | describe('config', () => { 4 | it('should get default config', () => { 5 | const config = getConfig(); 6 | expect(config).toMatchInlineSnapshot(` 7 | Object { 8 | "ADDON_ID": "adk", 9 | "EVENT_ID_BACK": "adk/event/back", 10 | "EVENT_ID_DATA": "adk/event/data", 11 | "EVENT_ID_INIT": "adk/event/init", 12 | "PANEL_ID": "adk/panel", 13 | "PANEL_Title": "adk/addon", 14 | "PARAM_Key": "adk/parameters", 15 | } 16 | `); 17 | }); 18 | 19 | it('should set config', () => { 20 | setConfig({ addonId: 'test' }); 21 | expect(getConfig()).toMatchInlineSnapshot(` 22 | Object { 23 | "ADDON_ID": "test", 24 | "EVENT_ID_BACK": "test/event/back", 25 | "EVENT_ID_DATA": "test/event/data", 26 | "EVENT_ID_INIT": "test/event/init", 27 | "PANEL_ID": "test/panel", 28 | "PANEL_Title": "test/addon", 29 | "PARAM_Key": "test/parameters", 30 | } 31 | `); 32 | 33 | setConfig({ panelTitle: 'ADK-TEST' }); 34 | expect(getConfig()).toMatchInlineSnapshot(` 35 | Object { 36 | "ADDON_ID": "test", 37 | "EVENT_ID_BACK": "test/event/back", 38 | "EVENT_ID_DATA": "test/event/data", 39 | "EVENT_ID_INIT": "test/event/init", 40 | "PANEL_ID": "test/panel", 41 | "PANEL_Title": "ADK-TEST", 42 | "PARAM_Key": "test/parameters", 43 | } 44 | `); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | let ADDON_ID = 'adk'; 2 | let PANEL_ID = `${ADDON_ID}/panel`; 3 | let PANEL_Title = `${ADDON_ID}/addon`; 4 | let PARAM_Key = `${ADDON_ID}/parameters`; 5 | let EVENT_ID_INIT = `${ADDON_ID}/event/init`; 6 | let EVENT_ID_DATA = `${ADDON_ID}/event/data`; 7 | let EVENT_ID_BACK = `${ADDON_ID}/event/back`; 8 | 9 | export const setConfig = ({ 10 | addonId, 11 | panelId, 12 | panelTitle, 13 | paramKey, 14 | eventInit, 15 | eventData, 16 | eventBack, 17 | }) => { 18 | ADDON_ID = addonId || ADDON_ID; 19 | PANEL_ID = `${ADDON_ID}/panel`; 20 | PANEL_Title = `${ADDON_ID}/addon`; 21 | PARAM_Key = `${ADDON_ID}/parameters`; 22 | EVENT_ID_INIT = `${ADDON_ID}/event/init`; 23 | EVENT_ID_DATA = `${ADDON_ID}/event/data`; 24 | EVENT_ID_BACK = `${ADDON_ID}/event/back`; 25 | 26 | PANEL_ID = panelId || PANEL_ID; 27 | PANEL_Title = panelTitle || PANEL_Title; 28 | PARAM_Key = paramKey || PARAM_Key; 29 | EVENT_ID_INIT = eventInit || EVENT_ID_INIT; 30 | EVENT_ID_DATA = eventData || EVENT_ID_DATA; 31 | EVENT_ID_BACK = eventBack || EVENT_ID_BACK; 32 | }; 33 | 34 | export const getConfig = () => ({ 35 | ADDON_ID, 36 | PANEL_ID, 37 | PANEL_Title, 38 | PARAM_Key, 39 | EVENT_ID_INIT, 40 | EVENT_ID_DATA, 41 | EVENT_ID_BACK, 42 | }) -------------------------------------------------------------------------------- /src/decorator.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import withChannel from './withChannel'; 3 | 4 | import { getConfig } from './config'; 5 | 6 | const createHOC = paramSelectors => { 7 | const DecoratorWrapper = ({ 8 | actions, 9 | selectors, 10 | Component, 11 | parameters, 12 | resetParameters, 13 | ...props 14 | }) => { 15 | let params = {}; 16 | if (paramSelectors) { 17 | try { 18 | const entries = Object.entries(paramSelectors); 19 | const paramResults = entries 20 | .map(([name, fn]) => { 21 | try { 22 | return { [name]: fn(parameters, selectors) }; 23 | } catch (err) { 24 | console.error(err); 25 | return null; 26 | } 27 | }) 28 | .filter(Boolean); 29 | params = paramResults.reduce((obj, item) => ({ ...obj, ...item }), {}); 30 | } catch (err) { 31 | console.error(err); 32 | } 33 | } 34 | return ; 35 | }; 36 | return DecoratorWrapper; 37 | }; 38 | 39 | export const createDecorator = ( 40 | storeSelectors, 41 | createActions, 42 | paramSelectors 43 | ) => (Component, { isGlobal = true } = {}) => initData => ( 44 | getStory, 45 | context 46 | ) => { 47 | const { 48 | ADDON_ID, 49 | EVENT_ID_INIT, 50 | EVENT_ID_DATA, 51 | EVENT_ID_BACK, 52 | PARAM_Key, 53 | } = getConfig(); 54 | 55 | const parameters = context.parameters && context.parameters[PARAM_Key]; 56 | const storyId = isGlobal ? null : context.id; 57 | 58 | const WithChannel = withChannel({ 59 | EVENT_ID_INIT, 60 | EVENT_ID_DATA, 61 | EVENT_ID_BACK, 62 | ADDON_ID, 63 | initData, 64 | panel: false, 65 | parameters, 66 | storyId, 67 | storeSelectors, 68 | createActions, 69 | })(createHOC(paramSelectors)); 70 | 71 | const getStoryAndInjectParams = ctx => { 72 | const { 73 | argTypes, 74 | args, 75 | globals, 76 | hooks, 77 | id, 78 | kind, 79 | loaded, 80 | name, 81 | parameters, 82 | story, 83 | viewMode, 84 | ...additionalArgs 85 | } = ctx || {}; 86 | try { 87 | if (!context.args) { 88 | context.args = {}; 89 | } 90 | Object.assign(context.args, additionalArgs); 91 | } catch (err) { 92 | console.error(err); 93 | } 94 | return getStory(additionalArgs); 95 | }; 96 | 97 | return ( 98 | 103 | ); 104 | }; 105 | 106 | export const setParameters = () => { 107 | const { PARAM_Key } = getConfig(); 108 | return params => ({ 109 | [PARAM_Key]: params, 110 | }); 111 | }; 112 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { types as addonTypes } from '@storybook/addons' 2 | import { API } from '@storybook/api' 3 | 4 | interface Dictionary { 5 | [key: string]: T 6 | } 7 | 8 | type AddonStore = Dictionary; 9 | 10 | type Selector = (store: AddonStore) => any; 11 | type ParamSelector = (parameters: { 12 | [key: string]: any 13 | }, selectors: { 14 | [key: string]: any 15 | }) => any; 16 | 17 | type ActionDispatcher = (...args: any[]) => void | Promise; 18 | type ActionGenerator = ( 19 | action: (store: AddonStore, ...args: any[]) => AddonStore | Promise 20 | ) => ActionDispatcher; 21 | type Actions = ({ local: ActionGenerator, global: ActionGenerator }) => Dictionary; 22 | 23 | type RegisterOptions = { 24 | type?: addonTypes 25 | initData?: Dictionary 26 | } 27 | 28 | export declare function register( 29 | storeSelectors?: Dictionary, 30 | createActions?: Actions 31 | ): (Component: React.ComponentType, options: RegisterOptions) => void; 32 | 33 | 34 | interface StoryContext { 35 | id: string; 36 | name: string; 37 | kind: string; 38 | [key: string]: any; 39 | parameters: Parameters; 40 | } 41 | 42 | export type StoryFn = (p?: StoryContext) => ReturnType; 43 | type DecoratorFunction = (fn: StoryFn, c: StoryContext) => ReturnType; 44 | 45 | /** 46 | * Options that controls decorator behavior 47 | */ 48 | type DecoratorOptions = { 49 | isGlobal: boolean, 50 | } 51 | 52 | export declare function createDecorator(storeSelectors?: Dictionary, 53 | createActions: Actions, 54 | paramSelectors?: Dictionary< 55 | (parameters: Dictionary, selectors: Dictionary<() => any>) => any 56 | > 57 | ): (Component: React.ComponentType, options: DecoratorOptions) => DecoratorFunction; 58 | 59 | type AddonParameters = Dictionary 60 | 61 | export declare function setParameters(): (T) => ({ 62 | [ConfigValues.PARAM_Key]: T 63 | }) 64 | 65 | type ConfigOptions = { 66 | addonId?: string 67 | panelId?: string 68 | panelTitle?: string 69 | paramKey?: string 70 | eventInit?: string 71 | eventData?: string 72 | eventBack?: string 73 | } 74 | 75 | export declare function setConfig(config: ConfigOptions): void 76 | 77 | type ConfigValues = { 78 | ADDON_ID: string 79 | PANEL_ID: string 80 | PANEL_Title: string 81 | PARAM_Key: string 82 | EVENT_ID_INIT: string 83 | EVENT_ID_DATA: string 84 | EVENT_ID_BACK: string 85 | } 86 | 87 | export declare function getConfig(): ConfigValues 88 | 89 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from './config'; 2 | export * from './register'; 3 | export * from './decorator'; 4 | export * from './Layout'; 5 | -------------------------------------------------------------------------------- /src/register.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import addons, { types as addonTypes } from '@storybook/addons'; 3 | import { STORY_CHANGED } from '@storybook/core-events'; 4 | import Rect from '@reach/rect'; 5 | 6 | import { getConfig } from './config'; 7 | import withChannel from './withChannel'; 8 | import { LayoutProvider } from './Layout'; 9 | 10 | // todo: remove 11 | const panelDimesions = rect => 12 | rect 13 | ? { 14 | width: rect.width, 15 | height: rect.height, 16 | isLandscape: rect.width >= rect.height, 17 | } 18 | : {}; 19 | 20 | const addonLayout = isLandscape => { 21 | const Layout = ({ style, children, ...props }) => ( 22 |
34 | {children} 35 |
36 | ); 37 | return Layout; 38 | }; 39 | 40 | const addonBlock = isLandscape => { 41 | const Block = ({ style, children, size, ...props }) => ( 42 |
58 | {children} 59 |
60 | ); 61 | return Block; 62 | }; 63 | 64 | class PanelHOC extends React.Component { 65 | constructor(props) { 66 | super(props); 67 | const urlState = props.api.getUrlState(); 68 | this.state = { 69 | ...urlState, 70 | }; 71 | props.api.on(STORY_CHANGED, (kind, story) => 72 | this.setState({ kind, story }) 73 | ); 74 | } 75 | render() { 76 | const Panel = this.props.component; 77 | const { api, active, data, setData, config, isFirstDataReceived } = this.props; 78 | const { ADDON_ID, PANEL_ID, PANEL_Title } = config; 79 | const { kind, story } = this.state; 80 | 81 | if (!active) return null; 82 | 83 | return ( 84 | 85 | {({ rect, ref }) => { 86 | const dim = panelDimesions(rect); 87 | const Layout = addonLayout(dim.isLandscape); 88 | const Block = addonBlock(dim.isLandscape); 89 | return ( 90 |
91 | 92 | 109 | 110 |
111 | ); 112 | }} 113 |
114 | ); 115 | } 116 | } 117 | 118 | export const register = (storeSelectors, createActions) => ( 119 | Panel, 120 | { type = addonTypes.PANEL, initData } = {} 121 | ) => { 122 | const config = getConfig(); 123 | const { 124 | EVENT_ID_INIT, 125 | EVENT_ID_DATA, 126 | EVENT_ID_BACK, 127 | ADDON_ID, 128 | PANEL_Title, 129 | PANEL_ID, 130 | } = config; 131 | 132 | const WithChannel = withChannel({ 133 | EVENT_ID_INIT, 134 | EVENT_ID_DATA, 135 | EVENT_ID_BACK, 136 | ADDON_ID, 137 | initData, 138 | panel: true, 139 | storeSelectors, 140 | createActions, 141 | })(PanelHOC); 142 | 143 | addons.register(ADDON_ID, api => { 144 | addons.add(PANEL_ID, { 145 | title: PANEL_Title, 146 | type, 147 | id: PANEL_ID, 148 | render: ({ active, key } = {}) => ( 149 | 156 | ), 157 | }); 158 | }); 159 | }; 160 | -------------------------------------------------------------------------------- /src/withChannel.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { getSingleStore, getNewStore } from './ChannelStore'; 4 | 5 | const getDisplayName = WrappedComponent => 6 | WrappedComponent.displayName || WrappedComponent.name || 'Component'; 7 | 8 | const tryToSelect = fn => store => { 9 | try { 10 | return fn(store); 11 | } catch (err) { 12 | console.warn(err); 13 | return undefined; 14 | } 15 | }; 16 | 17 | const withChannel = ({ 18 | EVENT_ID_INIT, 19 | EVENT_ID_DATA, 20 | EVENT_ID_BACK, 21 | ADDON_ID, 22 | initData, 23 | panel, 24 | parameters, 25 | storyId, 26 | storeSelectors = {}, 27 | createActions = {}, 28 | }) => WrappedComponent => 29 | class extends React.Component { 30 | static displayName = `WithChannel(${getDisplayName(WrappedComponent)})`; 31 | 32 | constructor(props, ...args) { 33 | super(props, ...args); 34 | const initStateData = { 35 | ...initData, 36 | ...props.initData, 37 | ...parameters, 38 | }; 39 | 40 | const isReceived = false; 41 | 42 | this.state = { 43 | data: initStateData, 44 | selectors: isReceived ? this.executeSelectors(initStateData) : {}, 45 | isReceived, 46 | }; 47 | 48 | this.store = (panel ? getSingleStore : getNewStore)({ 49 | EVENT_ID_INIT, 50 | EVENT_ID_DATA, 51 | EVENT_ID_BACK, 52 | name: props.pointName, 53 | initData: this.state.data, 54 | isPanel: this.isPanel, 55 | storyId, 56 | }); 57 | 58 | this.actions = this.prepareActions(); 59 | } 60 | 61 | isPanel = this.props.panel || panel; 62 | 63 | executeSelectors = store => { 64 | return Object.entries(storeSelectors) 65 | .map(([name, selector]) => ({ 66 | [name]: tryToSelect(selector)(store), 67 | })) 68 | .reduce((akk, cur) => ({ ...akk, ...cur }), {}); 69 | }; 70 | 71 | prepareActions = () => { 72 | const { 73 | createGlobalAction: global, 74 | createLocalAction: local, 75 | } = this.store; 76 | const isFn = typeof createActions === 'function'; 77 | const actions = isFn 78 | ? createActions({ global, local }) 79 | : Object.entries(createActions) 80 | .map(([name, reducer]) => ({ [name]: global(reducer) })) 81 | .reduce((acc, cur) => ({ ...acc, ...cur }), {}); 82 | return actions; 83 | }; 84 | 85 | componentDidMount() { 86 | this.debugLog('componentDidMount'); 87 | this.store.onData(this.onData); 88 | if (this.state.data && !this.isPanel) { 89 | this.store.onConnected(() => this.store.sendInit(this.state.data)); 90 | } 91 | this.store.connect(); 92 | } 93 | 94 | componentWillUnmount() { 95 | this.debugLog('componentWillUnmount'); 96 | this.store.disconnect(); 97 | } 98 | 99 | // debug = true; 100 | debug = false; 101 | 102 | debugLog = message => { 103 | if (!this.debug) { 104 | return; 105 | } 106 | console.log( 107 | this.store.isPanel ? 'Panel:\n' : 'Preview:\n', 108 | message, 109 | this.store.store 110 | ); 111 | }; 112 | 113 | onData = data => { 114 | this.setState({ 115 | data, 116 | isReceived: true, 117 | selectors: this.executeSelectors(data), 118 | }); 119 | }; 120 | 121 | resetParameters = parameters => { 122 | const initStateData = { 123 | ...initData, 124 | ...this.props.initData, 125 | ...parameters, 126 | }; 127 | this.setState({ 128 | data: initStateData, 129 | selectors: this.state.isReceived ? this.executeSelectors(initStateData) : {}, 130 | }); 131 | this.store.sendInit(initStateData); 132 | } 133 | 134 | render() { 135 | const { pointName, initData, active, onData, ...props } = this.props; 136 | const { data, isReceived } = this.state; 137 | 138 | if (active === false) return null; 139 | 140 | const initStateData = { 141 | ...initData, 142 | ...parameters, 143 | ...data, 144 | }; 145 | 146 | let selectors; 147 | try { 148 | selectors = this.executeSelectors(initStateData) 149 | } catch (err) { 150 | selectors = this.state.selectors; 151 | } 152 | 153 | return ( 154 | 166 | ); 167 | } 168 | }; 169 | 170 | export default withChannel; 171 | --------------------------------------------------------------------------------