Merge branch 'master' into patch-2

This commit is contained in:
Oleg Proskurin 2017-08-05 14:54:52 +03:00 committed by GitHub
commit 523fae4724
28 changed files with 647 additions and 148 deletions

View File

@ -27,7 +27,7 @@ module.exports = {
singleQuote: true,
},
],
quotes: [warn, 'single'],
quotes: [warn, 'single', { avoidEscape: true }],
'class-methods-use-this': ignore,
'arrow-parens': [warn, 'as-needed'],
'space-before-function-paren': ignore,

View File

@ -87,6 +87,12 @@ setOptions({
* @type {Regex}
*/
hierarchySeparator: null,
/**
* sidebar tree animations
* @type {Boolean}
*/
sidebarAnimations: true,
});
storybook.configure(() => require('./stories'), module);

View File

@ -72,6 +72,9 @@ For RN apps:
Once your app is started, changing the selected story in web browser will update the story displayed within your mobile app.
If you are using Android and you get the following error after running the app: `'websocket: connection error', 'Failed to connect to localhost/127.0.0.1:7007'`, you have to forward the port 7007 on your device/emulator to port 7007 on your local machine with the following command:
`adb reverse tcp:7007 tcp:7007`
## Using Haul-cli
[Haul](https://github.com/callstack-io/haul) is an alternative to the react-native packager and has several advantages in that it allows you to define your own loaders, and handles symlinks better.

View File

@ -23,6 +23,11 @@ squarespace:
title: Squarespace
description: Component design and development at Squarespace
site: http://squarespace.com
dbsbank:
logo: ./logos/dbsbank.svg
title: DBS Bank
description: DBS Bank consumer products improves performance and maintainability with Storybook!
site: https://www.dbs.com
coursera:
logo: ./logos/coursera.svg
title: Coursera

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="13.49 12.03 110.47 33.24">
<path fill="#FFF" fill-rule="evenodd" d="M19.486 18.034H40.74v21.254H19.486z" clip-rule="evenodd"/>
<path fill="#C00" d="M43.652 28.658v-.003c0-2.094.086-3.962 1.64-7.26.452-.955 1.442-2.357-.02-3.98-1.183-1.185-2.538-.986-3.446-.467.52-.908.72-2.263-.467-3.448-1.63-1.462-3.03-.47-3.99-.022-3.29 1.56-5.16 1.646-7.26 1.646-2.1 0-3.97-.086-7.26-1.646-.96-.448-2.36-1.44-3.98.022-1.19 1.186-.99 2.54-.47 3.448-.91-.522-2.26-.718-3.45.466-1.46 1.624-.47 3.026-.02 3.98 1.55 3.3 1.63 5.164 1.63 7.264 0 2.1-.09 3.972-1.64 7.264-.45.956-1.45 2.358.02 3.987 1.18 1.18 2.53.98 3.44.46-.52.9-.72 2.26.47 3.44 1.62 1.46 3.02.47 3.98.01 3.29-1.55 5.16-1.64 7.26-1.64 2.1 0 3.97.09 7.26 1.64.96.45 2.36 1.44 3.98-.02 1.18-1.19.98-2.54.46-3.45.91.52 2.26.72 3.45-.47 1.46-1.63.47-3.04.01-3.99-1.55-3.3-1.64-5.17-1.64-7.27zM40.05 38.62l-7.715-6.474s-1.04-1.017-2.223-1.017c-1.178 0-2.225 1.01-2.225 1.01l-7.71 6.47-.03-.03 6.48-7.72s1.015-1.05 1.015-2.23-1.016-2.22-1.016-2.22l-6.48-7.72.03-.03 7.713 6.47s1.04 1.02 2.22 1.02c1.18 0 2.22-1.02 2.22-1.02l7.71-6.478.02.027-6.47 7.72s-1.02 1.04-1.02 2.22c0 1.18 1.02 2.22 1.02 2.22l6.47 7.71-.02.02z"/>
<path fill="#000" d="M121.32 14.57l.275-.002.1 5.052-.307-.004c-.498-2.05-2.475-3.995-6.436-4.2-4.686-.244-6.715 2.547-6.736 5.183-.027 3.51 2.707 4.23 6.76 5.22 1.914.46 8.994 1.39 8.428 9.35-.394 5.56-5.19 8.2-11.984 7.99 0 0-2.82-.1-6.766-1.19-.738-.21-.922.04-1.225.58l-.32.01.01-5.45.31.02c.16.55.2 1.57 1.19 2.45.74.64 2.44 2.22 6.19 2.26 3.68.04 6.74-1.52 7.15-5.64.11-1.12-.02-3.66-2.5-4.83-1.95-.91-7.34-1.51-9.96-3.8 0 0-3.1-2.16-2.61-6.2.69-5.7 5.45-7.13 9.72-7.22 0 0 3.83-.03 6.87.76 0 0 .75.2 1.38-.037.24-.083.36-.24.43-.34zM94.597 27.693c6.947 1.724 7.367 6.738 7.266 8.146-.31 6.27-5.64 6.99-7.78 6.99H77.7l.017-.32c1.19-.16 2.082-.82 2.082-2.9l.1-21.62c.02-2.35-.6-3.06-1.9-3.22l-.03-.3h12.4c2.27 0 9.11-.46 9.7 5.84.46 5.03-5.07 7.14-5.48 7.36zm1.824 8.186c.13-6.09-4.34-7.71-8.96-7.96-.09-.01-.05-.24.01-.25 1.51-.06 7.49-.75 7.24-6.46-.24-5.56-4.46-5.68-6-5.7-1.26-.02-1.53-.02-2.06.01-.89.03-1.12.1-1.11 1.07 0 .12-.2 10.01-.31 16.97-.06 3.78-.07 6.7-.07 6.7.02.68-.07 1.39 1.64 1.47 1.76.08 4.04.15 5.62-.18 1.46-.32 3.94-1.37 4.03-5.68zm-32.65-21.4c5.9-.03 13.67 4.02 13.65 14.13-.02 8.79-6.37 14.21-12.24 14.21H50.29l.017-.32c.847-.14 1.776-.67 1.93-1.23.497-2.12.39-21.51.08-24.7-.09-1-.872-1.64-1.84-1.8l-.02-.31H63.78zm2.7 26.51c2.64-.95 5.92-5.46 5.38-12.57-.52-6.88-3.14-12.27-10.12-12.78 0 0-1.32-.1-2.43-.11-1.18-.01-1.44-.05-1.53 1.21-.16 2.37-.2 20.85-.05 23.53.03.36.07 1.24 1.59 1.39 2.78.27 5.08.05 7.17-.7z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,2 +1,3 @@
import '@storybook/addon-actions/register';
import '@storybook/addon-options/register';
import '@storybook/addon-links/register';

View File

@ -1,4 +1,9 @@
import { configure } from '@storybook/react';
import { setOptions } from '@storybook/addon-options';
setOptions({
sidebarAnimations: false,
});
function loadStories() {
require('../src/stories');

View File

@ -8,7 +8,7 @@ export const features = {
ESCAPE: 5,
NEXT_STORY: 6,
PREV_STORY: 7,
SEARCH: 8,
SHOW_SEARCH: 8,
DOWN_PANEL_IN_RIGHT: 9,
};
@ -54,7 +54,7 @@ export default function handle(e) {
return features.PREV_STORY;
case keycode('P'):
e.preventDefault();
return features.SEARCH;
return features.SHOW_SEARCH;
case keycode('J'):
e.preventDefault();
return features.DOWN_PANEL_IN_RIGHT;

View File

@ -9,6 +9,7 @@ export default {
url: 'https://github.com/storybooks/storybook',
sortStoriesByKind: false,
hierarchySeparator: '/',
sidebarAnimations: true,
},
},
load({ clientStore, provider }, _actions) {

View File

@ -10,8 +10,8 @@ export function keyEventToOptions(currentOptions, event) {
return { showDownPanel: !currentOptions.showDownPanel };
case features.LEFT_PANEL:
return { showLeftPanel: !currentOptions.showLeftPanel };
case features.SEARCH:
return { showSearchBox: !currentOptions.showSearchBox };
case features.SHOW_SEARCH:
return { showSearchBox: true };
case features.DOWN_PANEL_IN_RIGHT:
return { downPanelInRight: !currentOptions.downPanelInRight };
default:

View File

@ -21,6 +21,7 @@ const storyProps = [
'selectedHierarchy',
'selectedStory',
'onSelectStory',
'sidebarAnimations',
];
const LeftPanel = props =>

View File

@ -3,11 +3,10 @@ import PropTypes from 'prop-types';
import React from 'react';
import deepEqual from 'deep-equal';
import treeNodeTypes from './tree_node_type';
import createTreeDecorators from './tree_decorators';
import treeDecorators from './tree_decorators';
import treeStyle from './tree_style';
const namespaceSeparator = '@';
const keyCodeEnter = 13;
function createNodeKey({ namespaces, type }) {
return [...namespaces, [type]].join(namespaceSeparator);
@ -39,14 +38,12 @@ class Stories extends React.Component {
constructor(...args) {
super(...args);
this.onToggle = this.onToggle.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
const { selectedHierarchy } = this.props;
this.state = {
nodes: getSelectedNodes(selectedHierarchy),
};
this.treeDecorators = createTreeDecorators(this);
}
componentWillReceiveProps(nextProps) {
@ -84,12 +81,6 @@ class Stories extends React.Component {
}));
}
onKeyDown(event, node) {
if (event.keyCode === keyCodeEnter) {
this.onToggle(node, !node.toggled);
}
}
fireOnKind(kind) {
const { onSelectStory } = this.props;
if (onSelectStory) onSelectStory(kind, null);
@ -140,7 +131,7 @@ class Stories extends React.Component {
}
render() {
const { storiesHierarchy } = this.props;
const { storiesHierarchy, sidebarAnimations } = this.props;
const data = this.mapStoriesHierarchy(storiesHierarchy);
data.toggled = true;
@ -152,7 +143,8 @@ class Stories extends React.Component {
style={treeStyle}
data={data}
onToggle={this.onToggle}
decorators={this.treeDecorators}
animations={sidebarAnimations ? undefined : false}
decorators={treeDecorators}
/>
);
}
@ -173,6 +165,7 @@ Stories.propTypes = {
selectedKind: PropTypes.string.isRequired,
selectedStory: PropTypes.string.isRequired,
onSelectStory: PropTypes.func,
sidebarAnimations: PropTypes.bool.isRequired,
};
export default Stories;

View File

@ -1,9 +1,25 @@
import { shallow, mount } from 'enzyme';
import React from 'react';
import Stories from './index';
import { setContext } from '../../../../../compose';
import { createHierarchy } from '../../../libs/hierarchy';
const leftClick = { button: 0 };
describe('manager.ui.components.left_panel.stories', () => {
beforeEach(() =>
setContext({
clientStore: {
getAll() {
return { shortcutOptions: {} };
},
subscribe() {},
},
})
);
afterEach(() => setContext(null));
const data = createHierarchy([
{ kind: 'a', stories: ['a1', 'a2'] },
{ kind: 'b', stories: ['b1', 'b2'] },
@ -65,7 +81,7 @@ describe('manager.ui.components.left_panel.stories', () => {
const output = wrap.html();
expect(output).toMatch(/some/);
expect(output).not.toMatch(/name/);
expect(output).not.toMatch(/>name</);
expect(output).not.toMatch(/item1/);
expect(output).not.toMatch(/a1/);
expect(output).not.toMatch(/a2/);
@ -125,8 +141,8 @@ describe('manager.ui.components.left_panel.stories', () => {
/>
);
const kind = wrap.find('a').filterWhere(el => el.text() === 'some').last();
kind.simulate('click');
const kind = wrap.find('[data-name="some"]');
kind.simulate('click', leftClick);
const { nodes } = wrap.state();
@ -216,8 +232,8 @@ describe('manager.ui.components.left_panel.stories', () => {
/>
);
const kind = wrap.find('a').filterWhere(el => el.text() === 'a').last();
kind.simulate('click');
const kind = wrap.find('[data-name="a"]');
kind.simulate('click', leftClick);
expect(onSelectStory).toHaveBeenCalledWith('a', null);
});
@ -234,7 +250,7 @@ describe('manager.ui.components.left_panel.stories', () => {
/>
);
const kind = wrap.find('a').filterWhere(el => el.text() === 'a').last();
const kind = wrap.find('[data-name="a"]').filterWhere(el => el.text() === 'a').last();
kind.simulate('click');
onSelectStory.mockClear();
@ -255,8 +271,8 @@ describe('manager.ui.components.left_panel.stories', () => {
/>
);
const kind = wrap.find('a').filterWhere(el => el.text() === 'b1').last();
kind.simulate('click');
const kind = wrap.find('[data-name="b1"]');
kind.simulate('click', leftClick);
expect(onSelectStory).toHaveBeenCalledWith('b', 'b1');
});
@ -273,13 +289,13 @@ describe('manager.ui.components.left_panel.stories', () => {
/>
);
wrap.find('a').filterWhere(el => el.text() === 'another').last().simulate('click');
wrap.find('a').filterWhere(el => el.text() === 'space').last().simulate('click');
wrap.find('a').filterWhere(el => el.text() === '20').last().simulate('click');
wrap.find('[data-name="another"]').simulate('click', leftClick);
wrap.find('[data-name="space"]').simulate('click', leftClick);
wrap.find('[data-name="20"]').simulate('click', leftClick);
expect(onSelectStory).toHaveBeenCalledWith('another.space.20', null);
wrap.find('a').filterWhere(el => el.text() === 'b2').last().simulate('click');
wrap.find('[data-name="b2"]').simulate('click', leftClick);
expect(onSelectStory).toHaveBeenCalledWith('another.space.20', 'b2');
});
@ -296,23 +312,12 @@ describe('manager.ui.components.left_panel.stories', () => {
/>
);
wrap
.find('a')
.filterWhere(el => el.text() === 'another')
.last()
.simulate('keyDown', { keyCode: 13 });
wrap.find('[data-name="another"]').simulate('keyDown', { keyCode: 13 });
wrap
.find('a')
.filterWhere(el => el.text() === 'space')
.last()
.simulate('keyDown', { keyCode: 13 });
wrap.find('[data-name="space"]').simulate('keyDown', { keyCode: 13 });
wrap
.find('a')
.filterWhere(el => el.text() === '20')
.last()
.simulate('keyDown', { keyCode: 13 });
// enter press on native link triggers click event
wrap.find('[data-name="20"]').simulate('click', leftClick);
expect(onSelectStory).toHaveBeenCalledWith('another.space.20', null);
});

View File

@ -2,6 +2,11 @@ import { decorators } from 'react-treebeard';
import { IoChevronRight } from 'react-icons/lib/io';
import React from 'react';
import PropTypes from 'prop-types';
import RoutedLink from '../../../containers/routed_link';
import MenuItem from '../../menu_item';
import treeNodeTypes from './tree_node_type';
function noop() {}
function ToggleDecorator({ style }) {
const { height, width, arrow } = style;
@ -24,85 +29,92 @@ ToggleDecorator.propTypes = {
};
function ContainerDecorator(props) {
const { node } = props;
const { node, style, onClick } = props;
const { container, ...restStyles } = style;
if (node.root) {
return null;
}
return <decorators.Container {...props} />;
let containerStyle = container.reduce((acc, styles) => ({ ...acc, ...styles }), {});
const innerContainer = <decorators.Container {...props} style={restStyles} onClick={noop} />;
if (node.type !== treeNodeTypes.STORY) {
return (
<MenuItem style={containerStyle} onClick={onClick} data-name={node.name}>
{innerContainer}
</MenuItem>
);
}
const overrideParams = {
selectedKind: node.kind,
selectedStory: node.story,
};
containerStyle = {
...style.nativeLink,
...containerStyle,
};
return (
<RoutedLink
overrideParams={overrideParams}
style={containerStyle}
onClick={onClick}
data-name={node.name}
>
{innerContainer}
</RoutedLink>
);
}
ContainerDecorator.propTypes = {
style: PropTypes.shape({
container: PropTypes.array.isRequired,
}).isRequired,
node: PropTypes.shape({
root: PropTypes.bool,
type: PropTypes.oneOf([treeNodeTypes.NAMESPACE, treeNodeTypes.COMPONENT, treeNodeTypes.STORY])
.isRequired,
name: PropTypes.string.isRequired,
kind: PropTypes.string,
story: PropTypes.string,
}).isRequired,
onClick: PropTypes.func.isRequired,
};
function HeaderDecorator(props) {
const { style, node } = props;
let newStyle = style;
if (node.type === treeNodeTypes.STORY) {
newStyle = {
...style,
title: {
...style.title,
...style.storyTitle,
},
};
}
return <decorators.Header {...props} style={newStyle} />;
}
HeaderDecorator.propTypes = {
style: PropTypes.shape({
title: PropTypes.object.isRequired,
base: PropTypes.object.isRequired,
}).isRequired,
node: PropTypes.shape({
type: PropTypes.oneOf([treeNodeTypes.NAMESPACE, treeNodeTypes.COMPONENT, treeNodeTypes.STORY]),
}).isRequired,
};
function createHeaderDecoratorScope(parent) {
class HeaderDecorator extends React.Component {
constructor(...args) {
super(...args);
this.onKeyDown = this.onKeyDown.bind(this);
}
onKeyDown(event) {
const { onKeyDown } = parent;
const { node } = this.props;
onKeyDown(event, node);
}
// Prevent focusing on mousedown
onMouseDown(event) {
event.preventDefault();
}
render() {
const { style, node } = this.props;
const newStyleTitle = {
...style.title,
};
if (!node.children || !node.children.length) {
newStyleTitle.fontSize = '13px';
}
return (
<div
style={style.base}
role="menuitem"
tabIndex="0"
onKeyDown={this.onKeyDown}
onMouseDown={this.onMouseDown}
>
<a style={newStyleTitle}>
{node.name}
</a>
</div>
);
}
}
HeaderDecorator.propTypes = {
style: PropTypes.shape({
title: PropTypes.object.isRequired,
base: PropTypes.object.isRequired,
}).isRequired,
node: PropTypes.shape({
name: PropTypes.string.isRequired,
}).isRequired,
};
return HeaderDecorator;
}
export default function(parent) {
return {
...decorators,
Header: createHeaderDecoratorScope(parent),
Container: ContainerDecorator,
Toggle: ToggleDecorator,
};
}
export default {
...decorators,
Header: HeaderDecorator,
Container: ContainerDecorator,
Toggle: ToggleDecorator,
};

View File

@ -7,7 +7,7 @@ export default {
base: {
listStyle: 'none',
margin: 0,
padding: 0,
padding: '5px',
fontFamily: baseFonts.fontFamily,
fontSize: '15px',
minWidth: '200px',
@ -20,12 +20,19 @@ export default {
link: {
cursor: 'pointer',
position: 'relative',
overflow: 'hidden',
padding: '0px 5px',
display: 'block',
zIndex: 1,
},
activeLink: {
fontWeight: 'bold',
backgroundColor: '#EEE',
zIndex: 0,
},
nativeLink: {
color: 'inherit',
textDecoration: 'none',
},
toggle: {
base: {
@ -67,6 +74,9 @@ export default {
lineHeight: '24px',
verticalAlign: 'middle',
},
storyTitle: {
fontSize: '13px',
},
},
subtree: {
paddingLeft: '19px',

View File

@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
const keyCodeEnter = 13;
export default class MenuItem extends React.Component {
constructor(...args) {
super(...args);
this.onKeyDown = this.onKeyDown.bind(this);
}
// Prevent focusing on mousedown
onMouseDown(event) {
event.preventDefault();
}
onKeyDown(e) {
if (e.keyCode === keyCodeEnter) {
this.props.onClick(e);
}
}
render() {
const { children, ...restProps } = this.props;
return (
<div
role="menuitem"
tabIndex="0"
onKeyDown={this.onKeyDown}
onMouseDown={this.onMouseDown}
{...restProps}
>
{children}
</div>
);
}
}
MenuItem.propTypes = {
children: PropTypes.node.isRequired,
onClick: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,59 @@
import { shallow } from 'enzyme';
import React from 'react';
import MenuItem from './menu_item';
const keyCodeEnter = 13;
describe('manager.ui.components.menu_item', () => {
describe('render', () => {
test('should use "a" tag', () => {
const wrap = shallow(<MenuItem title="title">Content</MenuItem>);
expect(
wrap.matchesElement(
<div role="menuitem" tabIndex="0" title="title">
Content
</div>
)
).toBe(true);
});
});
describe('events', () => {
let onClick;
let wrap;
beforeEach(() => {
onClick = jest.fn();
wrap = shallow(<MenuItem onClick={onClick} />);
});
test('should call onClick on a click', () => {
wrap.simulate('click');
expect(onClick).toHaveBeenCalled();
});
test('should call onClick on enter key', () => {
const e = { keyCode: keyCodeEnter };
wrap.simulate('keyDown', e);
expect(onClick).toHaveBeenCalledWith(e);
});
test("shouldn't call onClick on other keys", () => {
wrap.simulate('keyDown', {});
expect(onClick).not.toHaveBeenCalled();
});
test('should prevent default on mousedown', () => {
const e = {
preventDefault: jest.fn(),
};
wrap.simulate('mouseDown', e);
expect(e.preventDefault).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,46 @@
import PropTypes from 'prop-types';
import React from 'react';
const LEFT_BUTTON = 0;
// Cmd/Ctrl/Shift/Alt + Click should trigger default browser behaviour. Same applies to non-left clicks
function isPlainLeftClick(e) {
return e.button === LEFT_BUTTON && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey;
}
export default class RoutedLink extends React.Component {
constructor(...args) {
super(...args);
this.onClick = this.onClick.bind(this);
}
onClick(e) {
if (this.props.onClick && isPlainLeftClick(e)) {
e.preventDefault();
this.props.onClick(e);
}
}
render() {
const { onClick, href, children, overrideParams, ...restProps } = this.props;
return (
<a onClick={this.onClick} href={href} {...restProps}>
{children}
</a>
);
}
}
RoutedLink.defaultProps = {
onClick: null,
href: '#',
children: null,
overrideParams: null,
};
RoutedLink.propTypes = {
onClick: PropTypes.func,
href: PropTypes.string,
children: PropTypes.node,
overrideParams: PropTypes.shape({}),
};

View File

@ -0,0 +1,97 @@
import { shallow } from 'enzyme';
import React from 'react';
import RoutedLink from './routed_link';
const LEFT_BUTTON = 0;
const MIDDLE_BUTTON = 1;
const RIGHT_BUTTON = 2;
describe('manager.ui.components.routed_link', () => {
describe('render', () => {
test('should use "a" tag', () => {
const wrap = shallow(
<RoutedLink href="href" title="title">
Content
</RoutedLink>
);
expect(
wrap.matchesElement(
<a href="href" title="title">
Content
</a>
)
).toBe(true);
});
});
describe('events', () => {
let e;
let onClick;
let wrap;
beforeEach(() => {
e = {
button: LEFT_BUTTON,
preventDefault: jest.fn(),
};
onClick = jest.fn();
wrap = shallow(<RoutedLink onClick={onClick} />);
});
test('should call onClick on a plain left click', () => {
wrap.simulate('click', e);
expect(onClick).toHaveBeenCalledWith(e);
expect(e.preventDefault).toHaveBeenCalled();
});
test("shouldn't call onClick on a middle click", () => {
e.button = MIDDLE_BUTTON;
wrap.simulate('click', e);
expect(onClick).not.toHaveBeenCalled();
expect(e.preventDefault).not.toHaveBeenCalled();
});
test("shouldn't call onClick on a right click", () => {
e.button = RIGHT_BUTTON;
wrap.simulate('click', e);
expect(onClick).not.toHaveBeenCalled();
expect(e.preventDefault).not.toHaveBeenCalled();
});
test("shouldn't call onClick on alt+click", () => {
e.altKey = true;
wrap.simulate('click', e);
expect(onClick).not.toHaveBeenCalled();
expect(e.preventDefault).not.toHaveBeenCalled();
});
test("shouldn't call onClick on ctrl+click", () => {
e.ctrlKey = true;
wrap.simulate('click', e);
expect(onClick).not.toHaveBeenCalled();
expect(e.preventDefault).not.toHaveBeenCalled();
});
test("shouldn't call onClick on cmd+click / win+click", () => {
e.metaKey = true;
wrap.simulate('click', e);
expect(onClick).not.toHaveBeenCalled();
expect(e.preventDefault).not.toHaveBeenCalled();
});
test("shouldn't call onClick on shift+click", () => {
e.shiftKey = true;
wrap.simulate('click', e);
expect(onClick).not.toHaveBeenCalled();
expect(e.preventDefault).not.toHaveBeenCalled();
});
});
});

View File

@ -1,17 +1,26 @@
import { document } from 'global';
import PropTypes from 'prop-types';
import React from 'react';
import ReactModal from 'react-modal';
import FuzzySearch from '@storybook/react-fuzzy';
import { features } from '../../../libs/key_events';
import { baseFonts } from './theme';
const searchBoxStyle = {
position: 'absolute',
backgroundColor: '#FFF',
top: '100px',
left: '50%',
marginLeft: '-215px',
...baseFonts,
const modalStyle = {
content: {
top: '100px',
right: 'auto',
bottom: 'auto',
left: '50%',
marginLeft: '-215px',
border: 'none',
padding: 0,
overflow: 'visible',
...baseFonts,
},
overlay: {
background: 'transparent',
},
};
const formatStories = stories => {
@ -61,11 +70,19 @@ export default class SearchBox extends React.Component {
this.fireOnKind = this.fireOnKind.bind(this);
}
// TODO: Remove this if and when https://github.com/reactjs/react-modal/issues/464 resolves
componentDidUpdate(prevProps) {
// remove current focus on opening to prevent firing 'enter' keyDowns on it when modal closes
if (this.props.showSearchBox && !prevProps.showSearchBox && document.activeElement) {
document.activeElement.blur();
}
}
onSelect(selected) {
const { handleEvent } = this.props;
const { onClose } = this.props;
if (selected.type === 'story') this.fireOnStory(selected.value, selected.kind);
else this.fireOnKind(selected.value);
handleEvent(features.SEARCH);
onClose();
}
fireOnKind(kind) {
@ -80,16 +97,20 @@ export default class SearchBox extends React.Component {
render() {
return (
<div style={searchBoxStyle}>
{this.props.showSearchBox &&
<FuzzySearch
list={formatStories(this.props.stories)}
onSelect={this.onSelect}
keys={['value', 'type']}
resultsTemplate={suggestionTemplate}
autoFocus
/>}
</div>
<ReactModal
isOpen={this.props.showSearchBox}
onRequestClose={this.props.onClose}
style={modalStyle}
contentLabel="Search"
>
<FuzzySearch
list={formatStories(this.props.stories)}
onSelect={this.onSelect}
keys={['value', 'type']}
resultsTemplate={suggestionTemplate}
autoFocus
/>
</ReactModal>
);
}
}
@ -100,5 +121,5 @@ SearchBox.propTypes = {
showSearchBox: PropTypes.bool.isRequired,
stories: PropTypes.arrayOf(PropTypes.object),
onSelectStory: PropTypes.func.isRequired,
handleEvent: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,99 @@
import { shallow } from 'enzyme';
import React from 'react';
import ReactModal from 'react-modal';
import FuzzySearch from '@storybook/react-fuzzy';
import SearchBox from './search_box';
describe('manager.ui.components.search_box', () => {
describe('render', () => {
test('should render FuzzySearch inside ReactModal', () => {
const wrap = shallow(<SearchBox showSearchBox />);
const modal = wrap.find(ReactModal);
expect(modal).toBePresent();
expect(modal).toHaveProp('isOpen', true);
expect(modal).toHaveProp('contentLabel', 'Search');
const search = modal.find(FuzzySearch);
expect(search).toBePresent();
expect(search).toHaveProp('keys', ['value', 'type']);
expect(search).toHaveProp('autoFocus', true);
});
test('should format stories', () => {
const stories = [
{
kind: 'a',
stories: ['b', 'c'],
},
];
const wrap = shallow(<SearchBox stories={stories} />);
const search = wrap.find(FuzzySearch);
const expectedList = [
{
type: 'kind',
value: 'a',
id: 1,
},
{
type: 'story',
value: 'b',
id: 2,
kind: 'a',
},
{
type: 'story',
value: 'c',
id: 3,
kind: 'a',
},
];
expect(search).toHaveProp('list', expectedList);
});
});
describe('events', () => {
test('should call the onClose prop when modal requests it', () => {
const onClose = jest.fn();
const wrap = shallow(<SearchBox onClose={onClose} />);
const modal = wrap.find(ReactModal);
modal.simulate('requestClose');
expect(onClose).toHaveBeenCalled();
});
test('should handle selecting a kind', () => {
const onSelectStory = jest.fn();
const onClose = jest.fn();
const wrap = shallow(<SearchBox onSelectStory={onSelectStory} onClose={onClose} />);
const modal = wrap.find(FuzzySearch);
modal.simulate('select', {
type: 'kind',
value: 'a',
});
expect(onSelectStory).toHaveBeenCalledWith('a', null);
expect(onClose).toHaveBeenCalledWith();
});
test('should handle selecting a story', () => {
const onSelectStory = jest.fn();
const onClose = jest.fn();
const wrap = shallow(<SearchBox onSelectStory={onSelectStory} onClose={onClose} />);
const modal = wrap.find(FuzzySearch);
modal.simulate('select', {
type: 'story',
value: 'a',
kind: 'b',
});
expect(onSelectStory).toHaveBeenCalledWith('b', 'a');
expect(onClose).toHaveBeenCalled();
});
});
});

View File

@ -38,7 +38,7 @@ export function getShortcuts(platform) {
// if it is mac platform
if (platform && platform.indexOf('mac') !== -1) {
return [
{ name: 'Toggle Search Box', keys: ['⌘ ⇧ P', '⌃ ⇧ P'] },
{ name: 'Show Search Box', keys: ['⌘ ⇧ P', '⌃ ⇧ P'] },
{ name: 'Toggle Action Logger position', keys: ['⌘ ⇧ J', '⌃ ⇧ J'] },
{ name: 'Toggle Fullscreen Mode', keys: ['⌘ ⇧ F', '⌃ ⇧ F'] },
{ name: 'Toggle Left Panel', keys: ['⌘ ⇧ L', '⌃ ⇧ L'] },
@ -49,7 +49,7 @@ export function getShortcuts(platform) {
}
return [
{ name: 'Toggle Search Box', keys: ['Ctrl + Shift + P'] },
{ name: 'Show Search Box', keys: ['Ctrl + Shift + P'] },
{ name: 'Toggle Action Logger position', keys: ['Ctrl + Shift + J'] },
{ name: 'Toggle Fullscreen Mode', keys: ['Ctrl + Shift + F'] },
{ name: 'Toggle Left Panel', keys: ['Ctrl + Shift + L'] },

View File

@ -5,13 +5,7 @@ export const config = {
insidePopState: false,
};
export function changeUrl(clientStore) {
// Do not change the URL if we are inside a popState event.
if (config.insidePopState) return;
const data = clientStore.getAll();
if (!data.selectedKind) return;
export function getUrlState(data) {
const { selectedKind, selectedStory, customQueryParams } = data;
const {
@ -36,7 +30,7 @@ export function changeUrl(clientStore) {
const url = `?${qs.stringify(urlObj)}`;
const state = {
return {
...urlObj,
full,
down,
@ -44,8 +38,17 @@ export function changeUrl(clientStore) {
panelRight,
url,
};
}
window.history.pushState(state, '', url);
export function changeUrl(clientStore) {
// Do not change the URL if we are inside a popState event.
if (config.insidePopState) return;
const data = clientStore.getAll();
if (!data.selectedKind) return;
const state = getUrlState(data);
window.history.pushState(state, '', state.url);
}
export function updateStore(queryParams, actions) {

View File

@ -6,8 +6,9 @@ import { createHierarchy, resolveStoryHierarchy } from '../libs/hierarchy';
export const mapper = (state, props, { actions }) => {
const actionMap = actions();
const { stories, selectedKind, selectedStory, uiOptions, storyFilter } = state;
const { name, url, sortStoriesByKind, hierarchySeparator } = uiOptions;
const { name, url, sortStoriesByKind, hierarchySeparator, sidebarAnimations } = uiOptions;
const filteredStories = filters.storyFilter(
stories,
storyFilter,
@ -29,6 +30,7 @@ export const mapper = (state, props, { actions }) => {
onStoryFilter: actionMap.ui.setStoryFilter,
openShortcutsHelp: actionMap.ui.toggleShortcutsHelp,
sidebarAnimations,
name,
url,
};

View File

@ -0,0 +1,14 @@
import RoutedLink from '../components/routed_link';
import genPoddaLoader from '../libs/gen_podda_loader';
import { getUrlState } from '../configs/handle_routing';
import compose from '../../../compose';
export function mapper(state, props) {
const { url } = getUrlState({ ...state, ...props.overrideParams });
return {
href: url,
};
}
export default compose(genPoddaLoader(mapper))(RoutedLink);

View File

@ -0,0 +1,21 @@
import { mapper } from './routed_link';
describe('manager.ui.containers.routed_link', () => {
describe('mapper', () => {
test('should give correct data', () => {
const state = {
shortcutOptions: {},
};
const props = {
overrideParams: {
selectedKind: 'kind',
selectedStory: 'story',
},
};
const { href } = mapper(state, props);
expect(href).toContain('selectedKind=kind');
expect(href).toContain('selectedStory=story');
});
});
});

View File

@ -8,7 +8,11 @@ export const mapper = (state, props, { actions }) => {
showSearchBox: state.shortcutOptions.showSearchBox,
stories: state.stories,
onSelectStory: actionMap.api.selectStory,
handleEvent: actionMap.shortcuts.handleEvent,
onClose() {
actionMap.shortcuts.setOptions({
showSearchBox: false,
});
},
};
};

View File

@ -0,0 +1,43 @@
import { mapper } from './search_box';
describe('manager.ui.containers.search_box', () => {
describe('mapper', () => {
test('should give correct data', () => {
const stories = [{ kind: 'sk', stories: ['dd'] }];
const state = {
shortcutOptions: {
showSearchBox: true,
},
stories,
};
const selectStory = () => 'selectStory';
const setOptions = jest.fn();
const props = {};
const env = {
actions: () => ({
api: {
selectStory,
},
shortcuts: {
setOptions,
},
}),
};
const data = mapper(state, props, env);
const expectedData = {
showSearchBox: true,
stories,
onSelectStory: selectStory,
};
expect(data).toMatchObject(expectedData);
data.onClose();
expect(setOptions).toHaveBeenCalledWith({
showSearchBox: false,
});
});
});
});