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 { /> ); - 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 ( + + {innerContainer} + + ); + } + + 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 ( -
- - {node.name} - -
- ); - } + ...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(Content); + + 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'); + }); + }); +});