├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .storybook ├── addons.js ├── config.js ├── storybook-config.json └── storybook-config.template.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __mocks__ └── styleMock.js ├── docs └── versions-demo.gif ├── example ├── Button.js ├── Component.js └── story.js ├── package-lock.json ├── package.json ├── register.js └── src ├── panel ├── __tests__ │ ├── __snapshots__ │ │ └── panel.js.snap │ └── panel.js ├── index.js └── styles.css ├── register.js └── utils ├── __mocks__ └── config.js ├── __tests__ ├── config.js └── generateLink.js ├── config.js └── generateLink.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", 3 | ["env", { 4 | "targets": { 5 | "browsers": ["last 2 versions", "safari >= 7"] 6 | } 7 | }] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | reports 3 | dist 4 | .storybook 5 | src/utils/createHash.js 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb"], 3 | "env": { 4 | "browser": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "rules": { 9 | "import/prefer-default-export": 0, 10 | "react/forbid-prop-types": 1, 11 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], 12 | "react/require-default-props": 1, 13 | "no-underscore-dangle": 1, 14 | "no-plusplus": 0, 15 | "no-unused-expressions": ["error", { "allowShortCircuit": true }] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | dist 4 | .idea/ 5 | .DS_Store 6 | .vscode 7 | storybook-static 8 | reports 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | example 3 | src 4 | stub 5 | .babelrc 6 | .editorconfig 7 | .eslintignore 8 | .eslintrc 9 | config/blabbr-config.js 10 | __mocks__ 11 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '../src/register'; 2 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure } from '@storybook/react'; 3 | 4 | // Now go through all the stories in the src tree 5 | function requireAll(context) { 6 | return context.keys().map(context) 7 | } 8 | 9 | function loadStories() { 10 | requireAll(require.context('../example/', true, /.+\/story.js$/)); 11 | } 12 | 13 | configure(loadStories, module); 14 | -------------------------------------------------------------------------------- /.storybook/storybook-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "storybook": { 3 | "versions": { 4 | "availableVersions": [ 5 | "0.2.4", 6 | "0.2.5", 7 | "0.2.6", 8 | "0.3.0" 9 | ], 10 | "hostname": "localhost:8000", 11 | "localhost": "localhost:9001", 12 | "regex": "\/([^\/]+?)\/?$" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.storybook/storybook-config.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "storybook": { 3 | "versions": { 4 | "availableVersions": [ 5 | "v1", 6 | "v2", 7 | "v3", 8 | "..." 9 | ], 10 | "hostname": "storybook-host:1234", 11 | "localhost": "localhost:1234", 12 | "regex": "\/([^\/]+?)\/?$" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are more than welcome. Please fork the repo and then submit a pull request. 4 | 5 | You may want to look at the issues to check if anything you are interested in is currently under development. 6 | 7 | For now we don't have any other formal requirements. 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Buildit 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 | # storybook-addon-versions 2 | 3 | This addon allows you to navigate different versions of your components, if you have a setup that produces a different static Storybook build for each of your versions. As such, if you build a static Storybook and host it in, say, the following directory structure: 4 | ``` 5 | - static-storybook 6 | |-- 0.0.1 7 | |-- 0.0.2 8 | |-- 0.1.2 9 | |-- 0.2.5 10 | ``` 11 | 12 | the addon will allow you to navigate the various versions via the `Versions` panel: 13 | 14 | ![Versions demo](./docs/versions-demo.gif) 15 | 16 | ## Configuration 17 | 18 | The addon attempts to get a list of available style guide versions from the root of your host. If they are found it will show a dropdown which then lets you navigate to the various versions, as such allowing users to see how a component may have changed over different versions. 19 | 20 | The versions are expected to be in a configuration file `storybook-config.json` at the root of your host. You can also mock this in local dev by adding a `storybook-config.json` in your local `.storybook/` folder. Here's some sample content: 21 | 22 | ``` 23 | { 24 | "storybook": { 25 | "versions": { 26 | "availableVersions": [ 27 | "0.0.1", 28 | "0.0.2", 29 | "0.1.2", 30 | "0.2.5" 31 | ], 32 | "hostname": "localhost:8000", 33 | "localhost": "localhost:9001", 34 | "regex": "\/([^\/]+?)\/?$" 35 | } 36 | } 37 | } 38 | ``` 39 | 40 | The options are: 41 | 42 | - `availableVersions`: An array of available versions. 43 | - `hostname`: The hostname of where the static builds are. For now you need to add the path if you are expecting links to 44 | work in a local dev build but *not* in your normal hosted config. 45 | - `localhost`: Where the local dev build is, when running in dev mode 46 | - `regex`: This is for a regular expression that will extract the version number for your URL. This is dependant on the way you store the static storybook builds. The example above will work for the format `http://localhost:port//` so for example, version `0.1.2` would be expected to be found like this `http://mystorybook/0.1.2/`. 47 | 48 | The config format is the same as for [blabbr](https://github.com/buildit/storybook-addon-blabbr). 49 | 50 | -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /docs/versions-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buildit/storybook-addon-versions/f690215eb6f06355da980f38a221818f07bc5cab/docs/versions-demo.gif -------------------------------------------------------------------------------- /example/Button.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Button = ({ label, onClick }) => ( 5 | 8 | ); 9 | 10 | Button.displayName = 'Button'; 11 | Button.propTypes = { 12 | label: PropTypes.string.isRequired, 13 | onClick: PropTypes.func, 14 | }; 15 | 16 | Button.defaultProps = { 17 | onClick: null, 18 | }; 19 | 20 | export default Button; 21 | -------------------------------------------------------------------------------- /example/Component.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Component = () => ( 5 |
6 | This is a component 7 |
8 | ); 9 | 10 | export default Component; 11 | -------------------------------------------------------------------------------- /example/story.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import Button from './Button'; 4 | import Component from './Component'; 5 | 6 | storiesOf('Button') 7 | .add('default button', () => 94 | ); 95 | }); 96 | 97 | if (showLocalhost) { 98 | versionsList.unshift( 99 | , 104 | ); 105 | } 106 | } 107 | 108 | return ( 109 |
110 | 116 |
{versionsList}
117 |
118 | ); 119 | } 120 | } 121 | 122 | Panel.propTypes = { 123 | // channel: PropTypes.object.isRequired, 124 | storybook: PropTypes.object.isRequired, 125 | location: PropTypes.object.isRequired, 126 | }; 127 | -------------------------------------------------------------------------------- /src/panel/styles.css: -------------------------------------------------------------------------------- 1 | .versions-panel-container { 2 | width: 100%; 3 | font-family: -apple-system, ".SFNSText-Regular", "San Francisco", 4 | Roboto, "Segoe UI", "Helvetica Neue", "Lucida Grande", sans-serif; 5 | font-size: 12px; 6 | color: rgb(68, 68, 68); 7 | margin: 0; 8 | padding: 5px; 9 | } 10 | 11 | .versions-panel-container .light-bg { 12 | background-color: white; 13 | } 14 | .versions-panel-container .light-bg.with-border { 15 | border: 1px solid rgb(236, 236, 236); 16 | border-radius: 2px; 17 | } 18 | 19 | .versions-panel-container .dark-bg { 20 | background-color: rgb(247, 247, 247); 21 | } 22 | .versions-panel-container .dark-bg.with-border { 23 | border: 1px solid rgb(193, 193, 193); 24 | border-radius: 2px; 25 | } 26 | 27 | .versions-panel-container label { 28 | float: right; 29 | margin-right: 0.5em; 30 | user-select: none; 31 | cursor: pointer; 32 | } 33 | .versions-panel-container input { 34 | margin: 0; 35 | padding: 0; 36 | cursor: pointer; 37 | } 38 | 39 | .versions-panel-container .versions-panel-list { 40 | clear: both; 41 | } 42 | 43 | .versions-panel-container .versions-panel-list button, 44 | .versions-panel-container .versions-panel-list span { 45 | color: rgb(68, 68, 68); 46 | display: inline-block; 47 | padding: 0.25em 0.5em; 48 | margin: 0.25em 0.25em 0 0; 49 | text-decoration: none; 50 | font-size: 2em; 51 | } 52 | 53 | .versions-panel-container .versions-panel-list button:hover { 54 | background-color: rgb(247, 247, 247); 55 | border: 1px solid rgb(236, 236, 236); 56 | } 57 | -------------------------------------------------------------------------------- /src/register.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import addons from '@storybook/addons'; 3 | import Panel from './panel'; 4 | 5 | addons.register('buildit/versions', (api) => { 6 | const channel = addons.getChannel(); 7 | 8 | addons.addPanel('buildit/versions', { 9 | title: 'versions', 10 | render: () => ( 11 | 17 | ), 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/utils/__mocks__/config.js: -------------------------------------------------------------------------------- 1 | const configFile = require('../../../.storybook/storybook-config.json'); 2 | 3 | const getConfig = () => ( 4 | new Promise((resolve) => { 5 | resolve(configFile.storybook.versions); 6 | }) 7 | ); 8 | 9 | export default getConfig; 10 | -------------------------------------------------------------------------------- /src/utils/__tests__/config.js: -------------------------------------------------------------------------------- 1 | import getConfig from '../config'; 2 | 3 | const file1 = { 4 | storybook: { 5 | versions: { 6 | availableVersions: [ 7 | '0.1', 8 | '0.2', 9 | '0.3', 10 | ], 11 | }, 12 | }, 13 | }; 14 | 15 | const file2 = { 16 | storybook: { 17 | versions: { 18 | availableVersions: [ 19 | '0.2.4', 20 | '0.2.5', 21 | '0.2.6', 22 | '0.3.0', 23 | ], 24 | }, 25 | }, 26 | }; 27 | 28 | const invalid = { 29 | foo: 'bar', 30 | }; 31 | 32 | describe('Config', () => { 33 | beforeAll(() => { 34 | global.fetch = jest.fn().mockImplementation((location) => { 35 | let p; 36 | if (location.search('storybook-config.json') !== -1) { 37 | p = new Promise((resolve) => { 38 | resolve({ 39 | ok: true, 40 | json: () => new Promise(res => res(file1)), 41 | }); 42 | }); 43 | } else if (location.search('filename.json') !== -1) { 44 | p = new Promise((resolve) => { 45 | resolve({ 46 | ok: true, 47 | json: () => new Promise(res => res(file2)), 48 | }); 49 | }); 50 | } else if (location.search('invalid.json') !== -1) { 51 | p = new Promise((resolve) => { 52 | resolve({ 53 | ok: true, 54 | json: () => new Promise(res => res(invalid)), 55 | }); 56 | }); 57 | } else if (location.search('response_not_ok.json') !== -1) { 58 | p = new Promise((resolve) => { 59 | resolve({ 60 | ok: false, 61 | json: () => new Promise(res => res(invalid)), 62 | }); 63 | }); 64 | } else { 65 | p = new Promise((resolve, reject) => reject()); 66 | } 67 | return p; 68 | }); 69 | }); 70 | afterAll(() => { 71 | global.fetch.mockRestore(); 72 | }); 73 | 74 | it('Get the default config if no filename supplied', async () => { 75 | expect.assertions(1); 76 | await expect(getConfig()).resolves.toEqual(file1.storybook.versions); 77 | }); 78 | 79 | it('Get the specified config when a filename is supplied', async () => { 80 | expect.assertions(1); 81 | await expect(getConfig('filename.json')).resolves.toEqual(file2.storybook.versions); 82 | }); 83 | 84 | it('Throws an error when the config is inavlid', async () => { 85 | expect.assertions(1); 86 | await expect(getConfig('invalid.json')).rejects.toEqual('Invalid config'); 87 | }); 88 | 89 | it('Throws an error when the response is not ok', async () => { 90 | expect.assertions(1); 91 | await expect(getConfig('response_not_ok.json')).rejects.toEqual('Response not ok'); 92 | }); 93 | 94 | it('Throw an error when an invalid file is requested', async () => { 95 | expect.assertions(1); 96 | await expect(getConfig('error.json')).rejects.toEqual('Error getting config'); 97 | }); 98 | 99 | it('Caches the results if the same file is requested', async () => { 100 | expect.assertions(4); 101 | const initVal = fetch.mock.calls.length; 102 | await expect(getConfig('filename.json')).resolves.toEqual(file2.storybook.versions); 103 | expect(fetch.mock.calls.length).toBe(1 + initVal); 104 | await expect(getConfig('filename.json')).resolves.toEqual(file2.storybook.versions); 105 | expect(fetch.mock.calls.length).toBe(1 + initVal); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/utils/__tests__/generateLink.js: -------------------------------------------------------------------------------- 1 | import generateLink from '../generateLink'; 2 | 3 | const location = { 4 | protocol: 'https:', 5 | pathname: '/abc/0.1.2/', 6 | search: '?search_field', 7 | hash: 'hash_field', 8 | }; 9 | 10 | describe('Generate link', () => { 11 | it('Generates # link when no args (none)', () => { 12 | expect(generateLink()).toBe('#'); 13 | }); 14 | 15 | it('Generates # link when no args (location)', () => { 16 | expect(generateLink(location)).toBe('#'); 17 | }); 18 | 19 | it('Generates # link when no args (location, current)', () => { 20 | expect(generateLink(location, 'current')).toBe('#'); 21 | }); 22 | 23 | it('Generates # link when no args (location, current, target)', () => { 24 | expect(generateLink(location, 'current', 'target')).toBe('#'); 25 | }); 26 | 27 | it('Generates correct links (location, "", "", hostname)', () => { 28 | expect(generateLink(location, '', '', 'jest_hostname')) 29 | .toBe('https://jest_hostname/?search_fieldhash_field'); 30 | }); 31 | 32 | it('Generates correct links (location, "", target, hostname)', () => { 33 | expect(generateLink(location, '', '1.2.3', 'jest_hostname')) 34 | .toBe('https://jest_hostname/1.2.3/?search_fieldhash_field'); 35 | }); 36 | 37 | it('Generates correct links (all)', () => { 38 | expect(generateLink(location, '0.1.2', '1.2.3', 'jest_hostname')) 39 | .toBe('https://jest_hostname/abc/1.2.3/?search_fieldhash_field'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/utils/config.js: -------------------------------------------------------------------------------- 1 | let configFile = null; 2 | let lastFilename = null; 3 | 4 | const getConfig = (filename = 'storybook-config.json') => ( 5 | new Promise((resolve, reject) => { 6 | if (lastFilename === filename && configFile) { 7 | resolve(configFile); 8 | } else if (window && window.parent) { 9 | lastFilename = filename; 10 | const url = window.parent.location; 11 | const origin = `${url.protocol}//${url.hostname}:${url.port}`; 12 | 13 | const fetchConfig = pathParts => fetch(`${origin}/${pathParts.join('/')}${filename}`).then((response) => { 14 | if (response.ok) { 15 | response.json().then((data) => { 16 | if (data && data.storybook && data.storybook.versions) { 17 | configFile = data.storybook.versions; 18 | resolve(configFile); 19 | } else { 20 | reject('Invalid config'); 21 | } 22 | }); 23 | } else { 24 | reject('Response not ok'); 25 | } 26 | }).catch(() => { 27 | if (pathParts.filter(_ => _).length === 0) { 28 | throw new Error('Error getting config'); 29 | } 30 | 31 | return fetchConfig(pathParts.slice(0, pathParts.length - 2).concat([''])); 32 | }); 33 | 34 | fetchConfig(url.pathname.split('/').filter(_ => _).concat([''])).catch((e) => { 35 | reject(e.message); 36 | }); 37 | } else { 38 | reject('Window not found'); 39 | } 40 | }) 41 | ); 42 | 43 | export default getConfig; 44 | -------------------------------------------------------------------------------- /src/utils/generateLink.js: -------------------------------------------------------------------------------- 1 | const generateLink = (location, current, target, hostname) => { 2 | if (location && hostname) { 3 | let path; 4 | 5 | if (current) { 6 | path = location.pathname.replace(current, target); 7 | } else if (target) { 8 | path = `/${target}/`; 9 | } else { 10 | path = '/'; 11 | } 12 | 13 | return `${location.protocol}//${hostname}${path}${location.search}${location.hash}`; 14 | } 15 | 16 | return '#'; 17 | }; 18 | 19 | export default generateLink; 20 | --------------------------------------------------------------------------------