diff --git a/.gitignore b/.gitignore index cbaf6162bbd4..b81f584793d0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,3 @@ yarn.lock /**/LICENSE docs/public packs/*.tgz -package-lock.json diff --git a/addons/actions/README.md b/addons/actions/README.md index bd2801310850..19117bc40d4e 100644 --- a/addons/actions/README.md +++ b/addons/actions/README.md @@ -25,9 +25,7 @@ npm i -D @storybook/addon-actions Then, add following content to `.storybook/addons.js` -``` -import '@storybook/addon-actions/register'; -``` + import '@storybook/addon-actions/register'; Import the `action` function and use it to create actions handlers. When creating action handlers, provide a **name** to make it easier to identify. diff --git a/addons/info/README.md b/addons/info/README.md index e567e7240d69..1f5288b06842 100644 --- a/addons/info/README.md +++ b/addons/info/README.md @@ -55,24 +55,6 @@ storiesOf('Component') > Have a look at [this example](example/story.js) stories to learn more about the `addWithInfo` API. - -To customize your defaults: - -```js -// config.js -import infoAddon, { setDefaults } from '@storybook/addon-info'; - -// addon-info -setDefaults({ - inline: true, - maxPropsIntoLine: 1, - maxPropObjectKeys: 10, - maxPropArrayLength: 10, - maxPropStringLength: 100, -}); -setAddon(infoAddon); -``` - ## The FAQ **Components lose their names on static build** diff --git a/addons/info/src/components/Story.js b/addons/info/src/components/Story.js index 618d64d4f312..7e412085cc40 100644 --- a/addons/info/src/components/Story.js +++ b/addons/info/src/components/Story.js @@ -125,7 +125,11 @@ export default class Story extends React.Component { _renderInline() { return (
- {this._renderInlineHeader()} +
+
+ {this._getInfoHeader()} +
+
{this._renderStory()}
@@ -141,19 +145,6 @@ export default class Story extends React.Component { ); } - _renderInlineHeader() { - const infoHeader = this._getInfoHeader(); - - return ( - infoHeader && -
-
- {infoHeader} -
-
- ); - } - _renderOverlay() { const linkStyle = { ...stylesheet.link.base, diff --git a/app/react-native/src/server/index.js b/app/react-native/src/server/index.js index 09e0a34284b9..ed284ab31b4d 100755 --- a/app/react-native/src/server/index.js +++ b/app/react-native/src/server/index.js @@ -12,12 +12,14 @@ export default class Server { this.expressApp.use(storybook(options)); this.httpServer.on('request', this.expressApp); this.wsServer = new ws.Server({ server: this.httpServer }); - this.wsServer.on('connection', (s, req) => this.handleWS(s, req)); + this.wsServer.on('connection', s => this.handleWS(s)); } - handleWS(socket, req) { + handleWS(socket) { if (this.options.manualId) { - const params = req.url ? querystring.parse(req.url.substr(1)) : {}; + const params = socket.upgradeReq && socket.upgradeReq.url + ? querystring.parse(socket.upgradeReq.url.substr(1)) + : {}; if (params.pairedId) { socket.pairedId = params.pairedId; // eslint-disable-line diff --git a/docs/pages/basics/faq/index.md b/docs/pages/basics/faq/index.md index a5da8ca8a29c..87a3c3759fc7 100644 --- a/docs/pages/basics/faq/index.md +++ b/docs/pages/basics/faq/index.md @@ -17,5 +17,5 @@ npm test -- --coverage --collectCoverageFrom='["src/**/*.{js,jsx}","!src/**/stor Next automatically defines `React` for all of your files via a babel plugin. You must define `React` for JSX to work. You can solve this either by: -1. Adding `import React from 'react'` to your component files. -1. Adding a `.babelrc` that includes [`babel-plugin-react-require`](https://www.npmjs.com/package/babel-plugin-react-require) +1. Adding `import React from 'react'` to your component files. +2. Adding a `.babelrc` that includes [`babel-plugin-react-require`](https://www.npmjs.com/package/babel-plugin-react-require) diff --git a/examples/cra-kitchen-sink/.storybook/config.js b/examples/cra-kitchen-sink/.storybook/config.js index 7e57d3f7fef0..cd396ec63556 100644 --- a/examples/cra-kitchen-sink/.storybook/config.js +++ b/examples/cra-kitchen-sink/.storybook/config.js @@ -11,7 +11,8 @@ setOptions({ showSearchBox: false, downPanelInRight: true, sortStoriesByKind: false, -}) + resolveStoryHierarchy: (storyName) => storyName.split('.'), +}); setAddon(infoAddon); diff --git a/examples/cra-kitchen-sink/src/stories/index.js b/examples/cra-kitchen-sink/src/stories/index.js index fabd50e219a2..b36b36fbc2f8 100644 --- a/examples/cra-kitchen-sink/src/stories/index.js +++ b/examples/cra-kitchen-sink/src/stories/index.js @@ -153,3 +153,43 @@ storiesOf('WithEvents', module) ) .add('Logger', () => ); + +storiesOf('component.base.Link') + .addDecorator(withKnobs) + .add('first', () => {text('firstLink', 'first link')}) + .add('second', () => {text('secondLink', 'second link')}); + +storiesOf('component.base.Span') + .add('first', () => first span) + .add('second', () => second span); + +storiesOf('component.common.Div') + .add('first', () =>
first div
) + .add('second', () =>
second div
); + +storiesOf('component.common.Table') + .add('first', () =>
first table
) + .add('second', () =>
first table
); + +storiesOf('component.Button') + .add('first', () => ) + .add('second', () => ); + +// Atomic + +storiesOf('Atoms.Molecules.Cells.simple', module) + .addDecorator(withKnobs) + .add('with text', () => ) + .add('with some emoji', () => ); + +storiesOf('Atoms.Molecules.Cells.more', module) + .add('with text2', () => ) + .add('with some emoji2', () => ); + +storiesOf('Atoms.Molecules', module) + .add('with text', () => ) + .add('with some emoji', () => ); + +storiesOf('Atoms.Molecules.Cells', module) + .add('with text2', () => ) + .add('with some emoji2', () => ); diff --git a/lib/cli/generators/REACT_NATIVE/index.js b/lib/cli/generators/REACT_NATIVE/index.js index e60007786675..f0318e212508 100644 --- a/lib/cli/generators/REACT_NATIVE/index.js +++ b/lib/cli/generators/REACT_NATIVE/index.js @@ -31,8 +31,7 @@ module.exports = latestVersion('@storybook/react-native').then(version => { packageJson.devDependencies['@storybook/react-native'] = `^${version}`; if (!packageJson.dependencies['react-dom'] && !packageJson.devDependencies['react-dom']) { - const reactVersion = packageJson.dependencies.react; - packageJson.devDependencies['react-dom'] = reactVersion; + packageJson.devDependencies['react-dom'] = '^15.5.4'; } packageJson.scripts = packageJson.scripts || {}; diff --git a/lib/cli/generators/REACT_NATIVE_SCRIPTS/index.js b/lib/cli/generators/REACT_NATIVE_SCRIPTS/index.js index b163469d2082..fe05fbdbf474 100644 --- a/lib/cli/generators/REACT_NATIVE_SCRIPTS/index.js +++ b/lib/cli/generators/REACT_NATIVE_SCRIPTS/index.js @@ -15,8 +15,7 @@ module.exports = latestVersion('@storybook/react-native').then(version => { packageJson.devDependencies['@storybook/react-native'] = `^${version}`; if (!packageJson.dependencies['react-dom'] && !packageJson.devDependencies['react-dom']) { - const reactVersion = packageJson.dependencies.react; - packageJson.devDependencies['react-dom'] = reactVersion; + packageJson.devDependencies['react-dom'] = '^15.5.4'; } packageJson.scripts = packageJson.scripts || {}; diff --git a/lib/ui/example/client/provider.js b/lib/ui/example/client/provider.js index e95f76c55c00..541db9d9c5c9 100644 --- a/lib/ui/example/client/provider.js +++ b/lib/ui/example/client/provider.js @@ -78,25 +78,62 @@ export default class ReactProvider extends Provider { this.api = api; this.api.setOptions({ name: 'REACT-STORYBOOK', + sortStoriesByKind: true, + resolveStoryHierarchy: storyName => storyName.split('/') }); // set stories - this.api.setStories([ + this.api.setStories(this.createStories()); + + // listen to the story change and update the preview. + this.api.onStory((kind, story) => { + this.globalState.emit('change', kind, story); + }); + } + + createStories() { + return [ { - kind: 'Component 1', + kind: 'some/name/Component 1', stories: ['State 1', 'State 2'], }, - { - kind: 'Component 2', + kind: 'some/name/Component 2', stories: ['State a', 'State b'], }, - ]); - - // listen to the story change and update the preview. - this.api.onStory((kind, story) => { - this.globalState.emit('change', kind, story); - }); + { + kind: 'some/name2/Component 3', + stories: ['State a', 'State b'], + }, + { + kind: 'some/name2', + stories: ['State a', 'State b'], + }, + { + kind: 'some/name2/Component 4', + stories: ['State a', 'State b'], + }, + { + kind: 'another/name3/Component 5', + stories: ['State a', 'State b'], + }, + { + kind: 'another/name3/Component 6', + stories: ['State a', 'State b'], + }, + { + kind: 'Bla 1', + stories: ['State 1', 'State 2'], + }, + { + kind: 'Bla 2', + stories: ['State 1', 'State 2'], + }, + { + kind: 'anotherComponent in the middle', + stories: ['State a', 'State b'], + }, + ] } _handlePreviewEvents() { diff --git a/lib/ui/src/modules/api/index.js b/lib/ui/src/modules/api/index.js index 704def8cde85..6141f8cdeeec 100755 --- a/lib/ui/src/modules/api/index.js +++ b/lib/ui/src/modules/api/index.js @@ -8,6 +8,7 @@ export default { name: 'STORYBOOK', url: 'https://github.com/storybooks/storybook', sortStoriesByKind: false, + resolveStoryHierarchy: storyName => [storyName], }, }, load({ clientStore, provider }, _actions) { diff --git a/lib/ui/src/modules/ui/components/left_panel/index.js b/lib/ui/src/modules/ui/components/left_panel/index.js index b38381643b21..e2f87da845d6 100755 --- a/lib/ui/src/modules/ui/components/left_panel/index.js +++ b/lib/ui/src/modules/ui/components/left_panel/index.js @@ -15,7 +15,13 @@ const mainStyle = { padding: '10px 0 10px 10px', }; -const storyProps = ['stories', 'selectedKind', 'selectedStory', 'onSelectStory']; +const storyProps = [ + 'storiesHierarchy', + 'selectedKind', + 'selectedHierarchy', + 'selectedStory', + 'onSelectStory', +]; const LeftPanel = props =>
@@ -26,12 +32,12 @@ const LeftPanel = props => onChange={text => props.onStoryFilter(text)} />
- {props.stories ? : null} + {props.storiesHierarchy ? : null}
; LeftPanel.defaultProps = { - stories: null, + storiesHierarchy: null, storyFilter: null, onStoryFilter: () => {}, openShortcutsHelp: null, @@ -40,7 +46,11 @@ LeftPanel.defaultProps = { }; LeftPanel.propTypes = { - stories: PropTypes.arrayOf(PropTypes.object), + storiesHierarchy: PropTypes.shape({ + namespaces: PropTypes.arrayOf(PropTypes.string), + current: PropTypes.string, + map: PropTypes.object, + }), storyFilter: PropTypes.string, onStoryFilter: PropTypes.func, diff --git a/lib/ui/src/modules/ui/components/left_panel/index.test.js b/lib/ui/src/modules/ui/components/left_panel/index.test.js index 7fc17e522cd9..b52d35bf383e 100755 --- a/lib/ui/src/modules/ui/components/left_panel/index.test.js +++ b/lib/ui/src/modules/ui/components/left_panel/index.test.js @@ -4,6 +4,7 @@ import LeftPanel from './index'; import Header from './header'; import TextFilter from './text_filter'; import Stories from './stories'; +import { createHierarchy } from '../../libs/hierarchy'; describe('manager.ui.components.left_panel.index', () => { test('should render Header and TextFilter by default', () => { @@ -22,17 +23,22 @@ describe('manager.ui.components.left_panel.index', () => { expect(wrap.find(Stories)).toBeEmpty(); }); - test('should render stories only if stories prop exists', () => { + test('should render stories only if storiesHierarchy prop exists', () => { const selectedKind = 'kk'; const selectedStory = 'bb'; - const stories = [{ kind: 'kk', stories: ['bb'] }]; + const storiesHierarchy = createHierarchy([{ kind: 'kk', stories: ['bb'] }]); const wrap = shallow( - + ); const header = wrap.find(Stories).first(); expect(header.props()).toMatchObject({ - stories, + storiesHierarchy, selectedKind, selectedStory, }); diff --git a/lib/ui/src/modules/ui/components/left_panel/stories.js b/lib/ui/src/modules/ui/components/left_panel/stories.js index aba168aded2c..2e91653faf6a 100755 --- a/lib/ui/src/modules/ui/components/left_panel/stories.js +++ b/lib/ui/src/modules/ui/components/left_panel/stories.js @@ -1,6 +1,32 @@ import PropTypes from 'prop-types'; import React from 'react'; import { baseFonts } from '../theme'; +import { isSelectedHierarchy } from '../../libs/hierarchy'; + +const hierarchySeparatorColor = '#CCC'; +const hierarchySeparatorOffset = '15px'; + +const baseListItemStyle = { + display: 'block', + cursor: 'pointer', +}; + +const kindStyle = { + ...baseListItemStyle, + fontSize: 15, + padding: '5px 0px', +}; + +const nameSpaceStyle = { + ...kindStyle, + color: '#8aa4d1', +}; + +const storyStyle = { + ...baseListItemStyle, + fontSize: 13, + padding: '5px 0px', +}; const listStyle = { ...baseFonts, @@ -9,19 +35,24 @@ const listStyle = { const listStyleType = { listStyleType: 'none', paddingLeft: 0, + margin: 0, }; -const kindStyle = { - fontSize: 15, - padding: '10px 0px', - cursor: 'pointer', - borderBottom: '1px solid #EEE', +const nestedListStyle = { + ...listStyleType, + paddingLeft: hierarchySeparatorOffset, + borderLeft: `1px solid ${hierarchySeparatorColor}`, }; -const storyStyle = { - fontSize: 13, - padding: '8px 0px 8px 10px', - cursor: 'pointer', +const separatorStyle = { + margin: 0, + padding: 0, + width: '5px', + position: 'absolute', + left: `-${hierarchySeparatorOffset}`, + top: '50%', + border: 'none', + borderTop: `1px solid ${hierarchySeparatorColor}`, }; class Stories extends React.Component { @@ -41,9 +72,28 @@ class Stories extends React.Component { if (onSelectStory) onSelectStory(selectedKind, story); } + renderMenuItem(item, style, onClick, displayName) { + return ( + + {displayName} + + ); + } + + renderMenuListItem(item, style, onClick, displayName) { + const listItemStyle = { position: 'relative' }; + + return ( +
  • +
    + {this.renderMenuItem(item, style, onClick, displayName)} +
  • + ); + } + renderStory(story) { const { selectedStory } = this.props; - const style = { display: 'block', ...storyStyle }; + const style = { ...storyStyle }; const props = { onClick: this.fireOnStory.bind(this, story), }; @@ -52,75 +102,92 @@ class Stories extends React.Component { style.fontWeight = 'bold'; } - return ( -
  • - - {story} - -
  • - ); + return this.renderMenuListItem(story, style, props.onClick, story); } - renderKind({ kind, stories }) { + renderKind({ kind, stories, name }) { const { selectedKind } = this.props; - const style = { display: 'block', ...kindStyle }; + const storyKindStyle = { ...kindStyle }; const onClick = this.fireOnKind.bind(this, kind); + const displayName = name || kind; + + const children = [this.renderMenuListItem(kind, storyKindStyle, onClick, displayName)]; if (kind === selectedKind) { - style.fontWeight = 'bold'; - return ( -
  • - - {kind} - -
    -
      - {stories.map(this.renderStory)} -
    -
    + storyKindStyle.fontWeight = 'bold'; + + children.push( +
  • +
      + {stories.map(this.renderStory)} +
  • ); } - return ( -
  • - - {kind} - -
  • - ); + return children; + } + + renderHierarchy({ map }) { + const { selectedHierarchy } = this.props; + const children = []; + + map.forEach((childItems, key) => { + childItems.forEach(value => { + const style = { ...nameSpaceStyle }; + const onClick = this.fireOnKind.bind(this, value.firstKind); + const isSelected = isSelectedHierarchy(value.namespaces, selectedHierarchy); + + if (isSelected) { + style.fontWeight = 'bold'; + } + + if (value.isNamespace) { + children.push( +
      + {this.renderMenuListItem(value.current, style, onClick, key)} + {isSelected && +
    • + {this.renderHierarchy(value)} +
    • } +
    + ); + } else { + children.push( +
      + {this.renderKind(value)} +
    + ); + } + }); + }); + + return children; } render() { - const { stories } = this.props; + const { storiesHierarchy } = this.props; + return (
    -
      - {stories.map(this.renderKind)} -
    + {this.renderHierarchy(storiesHierarchy)}
    ); } } Stories.defaultProps = { - stories: [], onSelectStory: null, + storiesHierarchy: null, }; Stories.propTypes = { - stories: PropTypes.arrayOf( - PropTypes.shape({ - kind: PropTypes.string, - stories: PropTypes.array, - }) - ), + storiesHierarchy: PropTypes.shape({ + namespaces: PropTypes.arrayOf(PropTypes.string), + current: PropTypes.string, + map: PropTypes.object, + }), + selectedHierarchy: PropTypes.arrayOf(PropTypes.string).isRequired, selectedKind: PropTypes.string.isRequired, selectedStory: PropTypes.string.isRequired, onSelectStory: PropTypes.func, diff --git a/lib/ui/src/modules/ui/components/left_panel/stories.test.js b/lib/ui/src/modules/ui/components/left_panel/stories.test.js index 841b06b456e6..7a810f011ddf 100755 --- a/lib/ui/src/modules/ui/components/left_panel/stories.test.js +++ b/lib/ui/src/modules/ui/components/left_panel/stories.test.js @@ -1,12 +1,20 @@ import { shallow } from 'enzyme'; import React from 'react'; import Stories from './stories'; +import { createHierarchy } from '../../libs/hierarchy'; describe('manager.ui.components.left_panel.stories', () => { describe('render', () => { test('should render stories - empty', () => { - const data = []; - const wrap = shallow(); + const data = createHierarchy([]); + const wrap = shallow( + + ); const list = wrap.find('div').first().children('div').last(); @@ -14,22 +22,96 @@ describe('manager.ui.components.left_panel.stories', () => { }); test('should render stories', () => { - const data = [{ kind: 'a', stories: ['a1', 'a2'] }, { kind: '20', stories: ['b1', 'b2'] }]; - const wrap = shallow(); + const data = createHierarchy([ + { kind: 'a', stories: ['a1', 'a2'] }, + { kind: '20', stories: ['b1', 'b2'] }, + ]); + const wrap = shallow( + + ); + + const output = wrap.html(); + + expect(output).toMatch(/20/); + expect(output).toMatch(/b2/); + }); + + test('should render stories with hierarchy - resolveStoryHierarchy is defined', () => { + const data = createHierarchy( + [ + { kind: 'some.name.item1', stories: ['a1', 'a2'] }, + { kind: 'another.space.20', stories: ['b1', 'b2'] }, + ], + name => name.split('.') + ); + const wrap = shallow( + + ); const output = wrap.html(); + expect(output).toMatch(/some/); + expect(output).not.toMatch(/name/); + expect(output).not.toMatch(/item1/); + expect(output).not.toMatch(/a1/); + expect(output).not.toMatch(/a2/); + expect(output).toMatch(/another/); + expect(output).toMatch(/space/); expect(output).toMatch(/20/); + expect(output).toMatch(/b1/); + expect(output).toMatch(/b2/); + }); + + test('should render stories without hierarchy - resolveStoryHierarchy is not defined', () => { + const data = createHierarchy([ + { kind: 'some.name.item1', stories: ['a1', 'a2'] }, + { kind: 'another.space.20', stories: ['b1', 'b2'] }, + ]); + const wrap = shallow( + + ); + + const output = wrap.html(); + + expect(output).toMatch(/some.name.item1/); + expect(output).not.toMatch(/a1/); + expect(output).not.toMatch(/a2/); + expect(output).toMatch(/another.space.20/); + expect(output).toMatch(/b1/); expect(output).toMatch(/b2/); }); }); describe('events', () => { test('should call the onSelectStory prop when a kind is clicked', () => { - const data = [{ kind: 'a', stories: ['a1', 'a2'] }, { kind: 'b', stories: ['b1', 'b2'] }]; + const data = createHierarchy([ + { kind: 'a', stories: ['a1', 'a2'] }, + { kind: 'b', stories: ['b1', 'b2'] }, + ]); const onSelectStory = jest.fn(); const wrap = shallow( - + ); const kind = wrap.find('a').filterWhere(el => el.text() === 'a').last(); @@ -39,10 +121,19 @@ describe('manager.ui.components.left_panel.stories', () => { }); test('should call the onSelectStory prop when a story is clicked', () => { - const data = [{ kind: 'a', stories: ['a1', 'a2'] }, { kind: 'b', stories: ['b1', 'b2'] }]; + const data = createHierarchy([ + { kind: 'a', stories: ['a1', 'a2'] }, + { kind: 'b', stories: ['b1', 'b2'] }, + ]); const onSelectStory = jest.fn(); const wrap = shallow( - + ); const kind = wrap.find('a').filterWhere(el => el.text() === 'b1').last(); @@ -50,5 +141,31 @@ describe('manager.ui.components.left_panel.stories', () => { expect(onSelectStory).toHaveBeenCalledWith('b', 'b1'); }); + + test('should call the onSelectStory prop when a namespace is clicked - resolveStoryHierarchy is defined', () => { + const data = createHierarchy( + [ + { kind: 'some.name.item1', stories: ['a1', 'a2'] }, + { kind: 'another.space.20', stories: ['b1', 'b2'] }, + ], + name => name.split('.') + ); + + const onSelectStory = jest.fn(); + const wrap = shallow( + + ); + + const kind = wrap.find('a').filterWhere(el => el.text() === 'another').last(); + kind.simulate('click'); + + expect(onSelectStory).toHaveBeenCalledWith('another.space.20', null); + }); }); }); diff --git a/lib/ui/src/modules/ui/containers/left_panel.js b/lib/ui/src/modules/ui/containers/left_panel.js index 8191ba886e11..5df4ac0c1d1a 100755 --- a/lib/ui/src/modules/ui/containers/left_panel.js +++ b/lib/ui/src/modules/ui/containers/left_panel.js @@ -2,16 +2,24 @@ import LeftPanel from '../components/left_panel'; import * as filters from '../libs/filters'; import genPoddaLoader from '../libs/gen_podda_loader'; import compose from '../../../compose'; +import { createHierarchy } from '../libs/hierarchy'; export const mapper = (state, props, { actions }) => { const actionMap = actions(); const { stories, selectedKind, selectedStory, uiOptions, storyFilter } = state; - const { name, url, sortStoriesByKind } = uiOptions; + const { name, url, sortStoriesByKind, resolveStoryHierarchy } = uiOptions; + const filteredStores = filters.storyFilter(stories, storyFilter, selectedKind, sortStoriesByKind); + + const storiesHierarchy = createHierarchy(filteredStores, resolveStoryHierarchy); + const selectedHierarchy = resolveStoryHierarchy + ? resolveStoryHierarchy(selectedKind) + : [selectedKind]; const data = { - stories: filters.storyFilter(stories, storyFilter, selectedKind, sortStoriesByKind), + storiesHierarchy, selectedKind, selectedStory, + selectedHierarchy, onSelectStory: actionMap.api.selectStory, storyFilter, diff --git a/lib/ui/src/modules/ui/containers/left_panel.test.js b/lib/ui/src/modules/ui/containers/left_panel.test.js index e0476046d13d..f5b4778ccc3e 100755 --- a/lib/ui/src/modules/ui/containers/left_panel.test.js +++ b/lib/ui/src/modules/ui/containers/left_panel.test.js @@ -6,9 +6,11 @@ describe('manager.ui.containers.left_panel', () => { const stories = [{ kind: 'sk', stories: ['dd'] }]; const selectedKind = 'sk'; const selectedStory = 'dd'; + const selectedHierarchy = ['sk']; const uiOptions = { name: 'foo', url: 'bar', + resolveStoryHierarchy: name => [name], }; const selectStory = () => 'selectStory'; const toggleShortcutsHelp = () => 'toggleShortcutsHelp'; @@ -34,8 +36,11 @@ describe('manager.ui.containers.left_panel', () => { }; const result = mapper(state, props, env); - expect(result.stories).toEqual(stories); + expect(result.storiesHierarchy.map).toEqual( + new Map([['sk', [{ ...stories[0], name: 'sk', namespaces: ['sk'] }]]]) + ); expect(result.selectedKind).toBe(selectedKind); + expect(result.selectedHierarchy).toEqual(selectedHierarchy); expect(result.selectedStory).toBe(selectedStory); expect(result.storyFilter).toBe(null); expect(result.onSelectStory).toBe(selectStory); @@ -54,6 +59,7 @@ describe('manager.ui.containers.left_panel', () => { const uiOptions = { name: 'foo', url: 'bar', + resolveStoryHierarchy: name => [name], }; const selectStory = () => 'selectStory'; const toggleShortcutsHelp = () => 'toggleShortcutsHelp'; @@ -79,10 +85,12 @@ describe('manager.ui.containers.left_panel', () => { }; const result = mapper(state, props, env); - expect(result.stories).toEqual([ - stories[0], // selected kind is always there. That's why this is here. - stories[1], - ]); + expect(result.storiesHierarchy.map).toEqual( + new Map([ + ['pk', [{ ...stories[0], name: 'pk', namespaces: ['pk'] }]], // selected kind is always there. That's why this is here. + ['ss', [{ ...stories[1], name: 'ss', namespaces: ['ss'] }]], + ]) + ); }); test('should filter and sort stories according to the given filter', () => { @@ -97,6 +105,7 @@ describe('manager.ui.containers.left_panel', () => { name: 'foo', url: 'bar', sortStoriesByKind: true, + resolveStoryHierarchy: name => [name], }; const selectStory = () => 'selectStory'; const toggleShortcutsHelp = () => 'toggleShortcutsHelp'; @@ -122,10 +131,12 @@ describe('manager.ui.containers.left_panel', () => { }; const result = mapper(state, props, env); - expect(result.stories).toEqual([ - stories[1], // selected kind is always there. That's why this is here. - stories[0], - ]); + expect(result.storiesHierarchy.map).toEqual( + new Map([ + ['pk', [{ ...stories[1], name: 'pk', namespaces: ['pk'] }]], // selected kind is always there. That's why this is here. + ['ss', [{ ...stories[0], name: 'ss', namespaces: ['ss'] }]], + ]) + ); }); }); }); diff --git a/lib/ui/src/modules/ui/libs/hierarchy.js b/lib/ui/src/modules/ui/libs/hierarchy.js new file mode 100644 index 000000000000..3376a977bb21 --- /dev/null +++ b/lib/ui/src/modules/ui/libs/hierarchy.js @@ -0,0 +1,70 @@ +function fillHierarchy(namespaces, hierarchy, story) { + if (namespaces.length === 1) { + const namespace = namespaces[0]; + const childItems = hierarchy.map.get(namespace) || []; + + childItems.push(story); + hierarchy.map.set(namespace, childItems); + return; + } + + const namespace = namespaces[0]; + const childItems = hierarchy.map.get(namespace) || []; + let childHierarchy = childItems.find(item => item.isNamespace); + + if (!childHierarchy) { + childHierarchy = { + isNamespace: true, + current: namespace, + namespaces: [...hierarchy.namespaces, namespace], + firstKind: story.kind, + map: new Map(), + }; + + childItems.push(childHierarchy); + hierarchy.map.set(namespace, childItems); + } + + fillHierarchy(namespaces.slice(1), childHierarchy, story); +} + +export function createHierarchy(stories, resolveNamespace) { + const hierarchyRoot = { + namespaces: [], + current: '', + map: new Map(), + }; + + if (!stories) { + return hierarchyRoot; + } + + const groupedStories = stories.map(story => { + const namespaces = resolveNamespace ? resolveNamespace(story.kind) : [story.kind]; + + return { + namespaces, + name: namespaces[namespaces.length - 1], + ...story, + }; + }); + + groupedStories.forEach(story => fillHierarchy(story.namespaces, hierarchyRoot, story)); + + return hierarchyRoot; +} + +export function isSelectedHierarchy(namespaces, selectedHierarchy) { + if (!namespaces || !selectedHierarchy) { + return false; + } + + if (namespaces.length > selectedHierarchy.length) { + return false; + } + + return namespaces.reduce( + (isSelected, namespace, index) => isSelected && namespace === selectedHierarchy[index], + true + ); +} diff --git a/lib/ui/src/modules/ui/libs/hierarchy.test.js b/lib/ui/src/modules/ui/libs/hierarchy.test.js new file mode 100644 index 000000000000..b0fac255e041 --- /dev/null +++ b/lib/ui/src/modules/ui/libs/hierarchy.test.js @@ -0,0 +1,193 @@ +import { createHierarchy, isSelectedHierarchy } from './hierarchy'; + +describe('manager.ui.libs.hierarchy', () => { + describe('createHierarchy', () => { + test('should return root hierarchy node if stories are undefined', () => { + const result = createHierarchy(); + + expect(result).toEqual({ + namespaces: [], + current: '', + map: new Map(), + }); + }); + + test('should return root hierarchy node if stories are empty', () => { + const result = createHierarchy([]); + + expect(result).toEqual({ + namespaces: [], + current: '', + map: new Map(), + }); + }); + + test('should return flat hierarchy if resolve function is undefined', () => { + const stories = [ + { kind: 'some.name.item1', stories: ['a1', 'a2'] }, + { kind: 'another.space.20', stories: ['b1', 'b2'] }, + ]; + + const result = createHierarchy(stories); + + const expected = [ + [ + 'some.name.item1', + [ + { + kind: 'some.name.item1', + name: 'some.name.item1', + namespaces: ['some.name.item1'], + stories: ['a1', 'a2'], + }, + ], + ], + [ + 'another.space.20', + [ + { + kind: 'another.space.20', + name: 'another.space.20', + namespaces: ['another.space.20'], + stories: ['b1', 'b2'], + }, + ], + ], + ]; + + expect(result.map).toEqual(new Map(expected)); + }); + + test('should return hierarchy if resolve function is defined', () => { + const stories = [ + { kind: 'some.name.item1', stories: ['a1', 'a2'] }, + { kind: 'another.space.20', stories: ['b1', 'b2'] }, + ]; + + const result = createHierarchy(stories, name => name.split('.')); + + const expected = new Map([ + [ + 'some', + [ + { + current: 'some', + firstKind: 'some.name.item1', + isNamespace: true, + namespaces: ['some'], + map: new Map([ + [ + 'name', + [ + { + current: 'name', + firstKind: 'some.name.item1', + isNamespace: true, + namespaces: ['some', 'name'], + map: new Map([ + [ + 'item1', + [ + { + kind: 'some.name.item1', + name: 'item1', + namespaces: ['some', 'name', 'item1'], + stories: ['a1', 'a2'], + }, + ], + ], + ]), + }, + ], + ], + ]), + }, + ], + ], + [ + 'another', + [ + { + current: 'another', + firstKind: 'another.space.20', + isNamespace: true, + namespaces: ['another'], + map: new Map([ + [ + 'space', + [ + { + current: 'space', + firstKind: 'another.space.20', + isNamespace: true, + namespaces: ['another', 'space'], + map: new Map([ + [ + '20', + [ + { + kind: 'another.space.20', + name: '20', + namespaces: ['another', 'space', '20'], + stories: ['b1', 'b2'], + }, + ], + ], + ]), + }, + ], + ], + ]), + }, + ], + ], + ]); + + expect(result.map).toEqual(expected); + }); + }); + + describe('isSelectedHierarchy', () => { + test('no parameters', () => { + const result = isSelectedHierarchy(); + + expect(result).toBeFalsy(); + }); + + test('namespaces array is bigger then selectedHierarchy array', () => { + const namespaces = ['some', 'namespace', 'here', 'it', 'is']; + const selectedHierarchy = ['some', 'namespace']; + + const result = isSelectedHierarchy(namespaces, selectedHierarchy); + + expect(result).toBeFalsy(); + }); + + test('namespaces array is not matching selectedHierarchy array', () => { + const namespaces = ['some', 'namespace']; + const selectedHierarchy = ['some', 'namespace2']; + + const result = isSelectedHierarchy(namespaces, selectedHierarchy); + + expect(result).toBeFalsy(); + }); + + test('namespaces array is matching selectedHierarchy array', () => { + const namespaces = ['some', 'namespace']; + const selectedHierarchy = ['some', 'namespace']; + + const result = isSelectedHierarchy(namespaces, selectedHierarchy); + + expect(result).toBeTruthy(); + }); + + test('namespaces array is matching selectedHierarchy array when selectedHierarchy is bigger', () => { + const namespaces = ['some', 'namespace']; + const selectedHierarchy = ['some', 'namespace', 'here', 'it', 'is']; + + const result = isSelectedHierarchy(namespaces, selectedHierarchy); + + expect(result).toBeTruthy(); + }); + }); +}); diff --git a/package.json b/package.json index 076b0be0e40c..a3ef97b387f3 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "jest": "^20.0.4", "jest-enzyme": "^3.2.0", "lerna": "2.0.0-rc.5", - "lint-staged": "^4.0.0", + "lint-staged": "^3.5.1", "markdown-it-anchor": "^4.0.0", "markdownlint-cli": "^0.3.1", "nodemon": "^1.11.0",