├── .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 | Button 1
32 |
33 | );
34 | }
35 | // adkParams({ currentTheme: 1 })
36 | )
37 | .add(
38 | 'Stories2',
39 | () => {
40 | return (
41 |
42 | Button 2
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 | [](https://badge.fury.io/js/%40storybook%2Faddon-devkit)
2 | 
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 | () => Make Awesome ,
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 |
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 | indInc()}> +
40 | indDec()}> -
41 |
42 | update({ themes: ['T1', 'T2', 'T3'] })}>
43 | Update
44 |
45 | comment()}> comment
46 | request()}> request
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 |
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 |
--------------------------------------------------------------------------------