diff --git a/.eslintrc.js b/.eslintrc.js
index cf381b315490..5110b2dc2708 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -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,
diff --git a/lib/ui/src/modules/ui/components/left_panel/stories_tree/index.js b/lib/ui/src/modules/ui/components/left_panel/stories_tree/index.js
index 254ab61b40e2..0df466616a00 100644
--- a/lib/ui/src/modules/ui/components/left_panel/stories_tree/index.js
+++ b/lib/ui/src/modules/ui/components/left_panel/stories_tree/index.js
@@ -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);
@@ -152,7 +143,7 @@ class Stories extends React.Component {
style={treeStyle}
data={data}
onToggle={this.onToggle}
- decorators={this.treeDecorators}
+ decorators={treeDecorators}
/>
);
}
diff --git a/lib/ui/src/modules/ui/components/left_panel/stories_tree/index.test.js b/lib/ui/src/modules/ui/components/left_panel/stories_tree/index.test.js
index dcde01d0ca52..57fa75bbefa7 100644
--- a/lib/ui/src/modules/ui/components/left_panel/stories_tree/index.test.js
+++ b/lib/ui/src/modules/ui/components/left_panel/stories_tree/index.test.js
@@ -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('a')
- .filterWhere(el => el.text() === 'space')
- .last()
- .simulate('keyDown', { keyCode: 13 });
-
- wrap
- .find('a')
- .filterWhere(el => el.text() === '20')
- .last()
- .simulate('keyDown', { keyCode: 13 });
+ wrap.find('[data-name="another"]').simulate('keyDown', { keyCode: 13 });
+
+ wrap.find('[data-name="space"]').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);
});
diff --git a/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_decorators.js b/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_decorators.js
index bc462c8d2bfc..dad02a0715b5 100644
--- a/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_decorators.js
+++ b/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_decorators.js
@@ -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 ;
+ let containerStyle = container.reduce((acc, styles) => ({ ...acc, ...styles }), {});
+ const innerContainer = ;
+
+ if (node.type !== treeNodeTypes.STORY) {
+ return (
+
+ );
+ }
+
+ const overrideParams = {
+ selectedKind: node.kind,
+ selectedStory: node.story,
+ };
+
+ containerStyle = {
+ ...style.nativeLink,
+ ...containerStyle,
+ };
+
+ return (
+
+ {innerContainer}
+
+ );
}
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 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;
+function HeaderDecorator(props) {
+ const { style, node } = props;
- onKeyDown(event, node);
- }
+ let newStyle = style;
- // Prevent focusing on mousedown
- onMouseDown(event) {
- event.preventDefault();
- }
-
- render() {
- const { style, node } = this.props;
-
- const newStyleTitle = {
+ if (node.type === treeNodeTypes.STORY) {
+ newStyle = {
+ ...style,
+ title: {
...style.title,
- };
-
- if (!node.children || !node.children.length) {
- newStyleTitle.fontSize = '13px';
- }
-
- return (
-
- );
- }
+ ...style.storyTitle,
+ },
+ };
}
- HeaderDecorator.propTypes = {
- style: PropTypes.shape({
- title: PropTypes.object.isRequired,
- base: PropTypes.object.isRequired,
- }).isRequired,
- node: PropTypes.shape({
- name: PropTypes.string.isRequired,
- }).isRequired,
- };
-
- return HeaderDecorator;
+ return ;
}
-export default function(parent) {
- return {
- ...decorators,
- Header: createHeaderDecoratorScope(parent),
- Container: ContainerDecorator,
- Toggle: ToggleDecorator,
- };
-}
+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,
+};
+
+export default {
+ ...decorators,
+ Header: HeaderDecorator,
+ Container: ContainerDecorator,
+ Toggle: ToggleDecorator,
+};
diff --git a/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_style.js b/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_style.js
index 6d7da6ede213..16700237d27b 100644
--- a/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_style.js
+++ b/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_style.js
@@ -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',
diff --git a/lib/ui/src/modules/ui/components/menu_item.js b/lib/ui/src/modules/ui/components/menu_item.js
new file mode 100644
index 000000000000..da4f4a86849e
--- /dev/null
+++ b/lib/ui/src/modules/ui/components/menu_item.js
@@ -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 (
+
+ {children}
+
+ );
+ }
+}
+
+MenuItem.propTypes = {
+ children: PropTypes.node.isRequired,
+ onClick: PropTypes.func.isRequired,
+};
diff --git a/lib/ui/src/modules/ui/components/menu_item.test.js b/lib/ui/src/modules/ui/components/menu_item.test.js
new file mode 100644
index 000000000000..b05046898b33
--- /dev/null
+++ b/lib/ui/src/modules/ui/components/menu_item.test.js
@@ -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();
+
+ expect(
+ wrap.matchesElement(
+
+ Content
+
+ )
+ ).toBe(true);
+ });
+ });
+
+ describe('events', () => {
+ let onClick;
+ let wrap;
+
+ beforeEach(() => {
+ onClick = jest.fn();
+ wrap = shallow();
+ });
+
+ 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();
+ });
+ });
+});
diff --git a/lib/ui/src/modules/ui/components/routed_link.js b/lib/ui/src/modules/ui/components/routed_link.js
new file mode 100644
index 000000000000..3e0fab05a6bf
--- /dev/null
+++ b/lib/ui/src/modules/ui/components/routed_link.js
@@ -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 (
+
+ {children}
+
+ );
+ }
+}
+
+RoutedLink.defaultProps = {
+ onClick: null,
+ href: '#',
+ children: null,
+ overrideParams: null,
+};
+
+RoutedLink.propTypes = {
+ onClick: PropTypes.func,
+ href: PropTypes.string,
+ children: PropTypes.node,
+ overrideParams: PropTypes.shape({}),
+};
diff --git a/lib/ui/src/modules/ui/components/routed_link.test.js b/lib/ui/src/modules/ui/components/routed_link.test.js
new file mode 100644
index 000000000000..5e94e33b7a62
--- /dev/null
+++ b/lib/ui/src/modules/ui/components/routed_link.test.js
@@ -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(
+
+ Content
+
+ );
+
+ expect(
+ wrap.matchesElement(
+
+ Content
+
+ )
+ ).toBe(true);
+ });
+ });
+
+ describe('events', () => {
+ let e;
+ let onClick;
+ let wrap;
+
+ beforeEach(() => {
+ e = {
+ button: LEFT_BUTTON,
+ preventDefault: jest.fn(),
+ };
+ onClick = jest.fn();
+ wrap = shallow();
+ });
+
+ 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();
+ });
+ });
+});
diff --git a/lib/ui/src/modules/ui/configs/handle_routing.js b/lib/ui/src/modules/ui/configs/handle_routing.js
index 832f521da432..058d69a6a21a 100755
--- a/lib/ui/src/modules/ui/configs/handle_routing.js
+++ b/lib/ui/src/modules/ui/configs/handle_routing.js
@@ -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,
};
+}
+
+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;
- window.history.pushState(state, '', url);
+ const state = getUrlState(data);
+ window.history.pushState(state, '', state.url);
}
export function updateStore(queryParams, actions) {
diff --git a/lib/ui/src/modules/ui/containers/routed_link.js b/lib/ui/src/modules/ui/containers/routed_link.js
new file mode 100644
index 000000000000..7ad3a2a9910f
--- /dev/null
+++ b/lib/ui/src/modules/ui/containers/routed_link.js
@@ -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);
diff --git a/lib/ui/src/modules/ui/containers/routed_link.test.js b/lib/ui/src/modules/ui/containers/routed_link.test.js
new file mode 100644
index 000000000000..5e58487ef0d4
--- /dev/null
+++ b/lib/ui/src/modules/ui/containers/routed_link.test.js
@@ -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');
+ });
+ });
+});