+
{leftPanel()}
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..4e23362ad5ba 100755
--- a/lib/ui/src/modules/ui/components/left_panel/index.js
+++ b/lib/ui/src/modules/ui/components/left_panel/index.js
@@ -2,20 +2,26 @@ import PropTypes from 'prop-types';
import React from 'react';
import pick from 'lodash.pick';
import Header from './header';
-import Stories from './stories';
+import Stories from './stories_tree';
import TextFilter from './text_filter';
const scrollStyle = {
height: 'calc(100vh - 105px)',
marginTop: 10,
- overflowY: 'auto',
+ overflow: 'auto',
};
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),
+ name: 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..3a2e4b219edd 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
@@ -3,7 +3,8 @@ import { shallow } from 'enzyme';
import LeftPanel from './index';
import Header from './header';
import TextFilter from './text_filter';
-import Stories from './stories';
+import Stories from './stories_tree';
+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
deleted file mode 100755
index aba168aded2c..000000000000
--- a/lib/ui/src/modules/ui/components/left_panel/stories.js
+++ /dev/null
@@ -1,129 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { baseFonts } from '../theme';
-
-const listStyle = {
- ...baseFonts,
-};
-
-const listStyleType = {
- listStyleType: 'none',
- paddingLeft: 0,
-};
-
-const kindStyle = {
- fontSize: 15,
- padding: '10px 0px',
- cursor: 'pointer',
- borderBottom: '1px solid #EEE',
-};
-
-const storyStyle = {
- fontSize: 13,
- padding: '8px 0px 8px 10px',
- cursor: 'pointer',
-};
-
-class Stories extends React.Component {
- constructor(...args) {
- super(...args);
- this.renderKind = this.renderKind.bind(this);
- this.renderStory = this.renderStory.bind(this);
- }
-
- fireOnKind(kind) {
- const { onSelectStory } = this.props;
- if (onSelectStory) onSelectStory(kind, null);
- }
-
- fireOnStory(story) {
- const { onSelectStory, selectedKind } = this.props;
- if (onSelectStory) onSelectStory(selectedKind, story);
- }
-
- renderStory(story) {
- const { selectedStory } = this.props;
- const style = { display: 'block', ...storyStyle };
- const props = {
- onClick: this.fireOnStory.bind(this, story),
- };
-
- if (story === selectedStory) {
- style.fontWeight = 'bold';
- }
-
- return (
-
-
- {story}
-
-
- );
- }
-
- renderKind({ kind, stories }) {
- const { selectedKind } = this.props;
- const style = { display: 'block', ...kindStyle };
- const onClick = this.fireOnKind.bind(this, kind);
-
- if (kind === selectedKind) {
- style.fontWeight = 'bold';
- return (
-
-
- {kind}
-
-
-
- {stories.map(this.renderStory)}
-
-
-
- );
- }
-
- return (
-
-
- {kind}
-
-
- );
- }
-
- render() {
- const { stories } = this.props;
- return (
-
-
- {stories.map(this.renderKind)}
-
-
- );
- }
-}
-
-Stories.defaultProps = {
- stories: [],
- onSelectStory: null,
-};
-
-Stories.propTypes = {
- stories: PropTypes.arrayOf(
- PropTypes.shape({
- kind: PropTypes.string,
- stories: PropTypes.array,
- })
- ),
- selectedKind: PropTypes.string.isRequired,
- selectedStory: PropTypes.string.isRequired,
- onSelectStory: PropTypes.func,
-};
-
-export default Stories;
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
deleted file mode 100755
index 841b06b456e6..000000000000
--- a/lib/ui/src/modules/ui/components/left_panel/stories.test.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import { shallow } from 'enzyme';
-import React from 'react';
-import Stories from './stories';
-
-describe('manager.ui.components.left_panel.stories', () => {
- describe('render', () => {
- test('should render stories - empty', () => {
- const data = [];
- const wrap = shallow(
);
-
- const list = wrap.find('div').first().children('div').last();
-
- expect(list.text()).toBe('');
- });
-
- test('should render stories', () => {
- const data = [{ 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/);
- });
- });
-
- 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 onSelectStory = jest.fn();
- const wrap = shallow(
-
- );
-
- const kind = wrap.find('a').filterWhere(el => el.text() === 'a').last();
- kind.simulate('click');
-
- expect(onSelectStory).toHaveBeenCalledWith('a', null);
- });
-
- test('should call the onSelectStory prop when a story is clicked', () => {
- const data = [{ 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();
- kind.simulate('click');
-
- expect(onSelectStory).toHaveBeenCalledWith('b', 'b1');
- });
- });
-});
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
new file mode 100644
index 000000000000..f4733814b74b
--- /dev/null
+++ b/lib/ui/src/modules/ui/components/left_panel/stories_tree/index.js
@@ -0,0 +1,178 @@
+import { Treebeard } from 'react-treebeard';
+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 treeStyle from './tree_style';
+
+const namespaceSeparator = '@';
+const keyCodeEnter = 13;
+
+function createNodeKey({ namespaces, type }) {
+ return [...namespaces, [type]].join(namespaceSeparator);
+}
+
+function getSelectedNodes(selectedHierarchy) {
+ return selectedHierarchy
+ .reduce((nodes, namespace, index) => {
+ const node = {};
+
+ node.type =
+ selectedHierarchy.length - 1 === index ? treeNodeTypes.COMPONENT : treeNodeTypes.NAMESPACE;
+
+ if (!nodes.length) {
+ node.namespaces = [namespace];
+ } else {
+ const lastNode = nodes[nodes.length - 1];
+ node.namespaces = [...lastNode.namespaces, [namespace]];
+ }
+
+ nodes.push(node);
+
+ return nodes;
+ }, [])
+ .reduce((nodesMap, node) => ({ ...nodesMap, [createNodeKey(node)]: true }), {});
+}
+
+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) {
+ const { selectedHierarchy: nextSelectedHierarchy = [] } = nextProps;
+ const { selectedHierarchy: currentSelectedHierarchy = [] } = this.props;
+
+ if (!deepEqual(nextSelectedHierarchy, currentSelectedHierarchy)) {
+ const selectedNodes = getSelectedNodes(nextSelectedHierarchy);
+
+ this.setState(prevState => ({
+ nodes: {
+ ...prevState.nodes,
+ ...selectedNodes,
+ },
+ }));
+ }
+ }
+
+ onToggle(node, toggled) {
+ if (node.story) {
+ this.fireOnKindAndStory(node.kind, node.story);
+ } else if (node.kind) {
+ this.fireOnKind(node.kind);
+ }
+
+ if (!node.namespaces) {
+ return;
+ }
+
+ this.setState(prevState => ({
+ nodes: {
+ ...prevState.nodes,
+ [node.key]: toggled,
+ },
+ }));
+ }
+
+ onKeyDown(event, node) {
+ if (event.keyCode === keyCodeEnter) {
+ this.onToggle(node, !node.toggled);
+ }
+ }
+
+ fireOnKind(kind) {
+ const { onSelectStory } = this.props;
+ if (onSelectStory) onSelectStory(kind, null);
+ }
+
+ fireOnKindAndStory(kind, story) {
+ const { onSelectStory } = this.props;
+ if (onSelectStory) onSelectStory(kind, story);
+ }
+
+ mapStoriesHierarchy(storiesHierarchy) {
+ const treeModel = {
+ namespaces: storiesHierarchy.namespaces,
+ name: storiesHierarchy.name,
+ };
+
+ if (storiesHierarchy.isNamespace) {
+ treeModel.type = treeNodeTypes.NAMESPACE;
+
+ if (storiesHierarchy.map.size > 0) {
+ treeModel.children = [];
+
+ storiesHierarchy.map.forEach(childItems => {
+ childItems.forEach(item => {
+ treeModel.children.push(this.mapStoriesHierarchy(item));
+ });
+ });
+ }
+ } else {
+ const { selectedStory, selectedKind } = this.props;
+
+ treeModel.kind = storiesHierarchy.kind;
+ treeModel.type = treeNodeTypes.COMPONENT;
+
+ treeModel.children = storiesHierarchy.stories.map(story => ({
+ kind: storiesHierarchy.kind,
+ story,
+ name: story,
+ active: selectedStory === story && selectedKind === storiesHierarchy.kind,
+ type: treeNodeTypes.STORY,
+ }));
+ }
+
+ treeModel.key = createNodeKey(treeModel);
+ treeModel.toggled = this.state.nodes[treeModel.key];
+
+ return treeModel;
+ }
+
+ render() {
+ const { storiesHierarchy } = this.props;
+
+ const data = this.mapStoriesHierarchy(storiesHierarchy);
+ data.toggled = true;
+ data.name = 'stories';
+ data.root = true;
+
+ return (
+
+ );
+ }
+}
+
+Stories.defaultProps = {
+ onSelectStory: null,
+ storiesHierarchy: null,
+};
+
+Stories.propTypes = {
+ storiesHierarchy: PropTypes.shape({
+ namespaces: PropTypes.arrayOf(PropTypes.string),
+ name: PropTypes.string,
+ map: PropTypes.object,
+ }),
+ selectedHierarchy: PropTypes.arrayOf(PropTypes.string).isRequired,
+ selectedKind: PropTypes.string.isRequired,
+ selectedStory: PropTypes.string.isRequired,
+ onSelectStory: PropTypes.func,
+};
+
+export default Stories;
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
new file mode 100644
index 000000000000..ffcca452097c
--- /dev/null
+++ b/lib/ui/src/modules/ui/components/left_panel/stories_tree/index.test.js
@@ -0,0 +1,358 @@
+import { shallow, mount } from 'enzyme';
+import React from 'react';
+import Stories from './index';
+import { createHierarchy } from '../../../libs/hierarchy';
+
+describe('manager.ui.components.left_panel.stories', () => {
+ describe('render', () => {
+ test('should render stories - empty', () => {
+ const data = createHierarchy([]);
+ const wrap = shallow(
+
+ );
+
+ const list = wrap.find('div').first().children('div').last();
+
+ expect(list.text()).toBe('');
+ });
+
+ test('should render stories', () => {
+ 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 - hierarchySeparator is 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/);
+ 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 - hierarchySeparator 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/);
+ });
+
+ test('should render stories with initially selected nodes according to the selectedHierarchy', () => {
+ const data = createHierarchy(
+ [
+ { kind: 'some.name.item1', stories: ['a1', 'a2'] },
+ { kind: 'another.space.20', stories: ['b1', 'b2'] },
+ ],
+ '\\.'
+ );
+ const wrap = shallow(
+
+ );
+
+ const { nodes } = wrap.state();
+
+ expect(nodes).toEqual({
+ 'another@namespace': true,
+ 'another@space@namespace': true,
+ 'another@space@20@component': true,
+ });
+ });
+
+ test('should contain state with all selected nodes after clicking on the nodes', () => {
+ const data = createHierarchy(
+ [
+ { kind: 'some.name.item1', stories: ['a1', 'a2'] },
+ { kind: 'another.space.20', stories: ['b1', 'b2'] },
+ ],
+ '\\.'
+ );
+ const wrap = mount(
+
+ );
+
+ const kind = wrap.find('a').filterWhere(el => el.text() === 'some').last();
+ kind.simulate('click');
+
+ const { nodes } = wrap.state();
+
+ expect(nodes).toEqual({
+ 'another@namespace': true,
+ 'another@space@namespace': true,
+ 'another@space@20@component': true,
+ 'some@namespace': true,
+ });
+ });
+
+ test('should recalculate selected nodes after selectedHierarchy changes', () => {
+ const data = createHierarchy(
+ [
+ { kind: 'some.name.item1', stories: ['a1', 'a2'] },
+ { kind: 'another.space.20', stories: ['b1', 'b2'] },
+ ],
+ '\\.'
+ );
+ const wrap = mount(
+
+ );
+
+ wrap.setProps({ selectedHierarchy: ['another', 'space', '20'] });
+
+ const { nodes } = wrap.state();
+
+ expect(nodes).toEqual({
+ 'another@namespace': true,
+ 'another@space@namespace': true,
+ 'another@space@20@component': true,
+ });
+ });
+
+ test('should add selected nodes to the state after selectedHierarchy changes with a new value', () => {
+ const data = createHierarchy(
+ [
+ { kind: 'some.name.item1', stories: ['a1', 'a2'] },
+ { kind: 'another.space.20', stories: ['b1', 'b2'] },
+ ],
+ '\\.'
+ );
+ const wrap = mount(
+
+ );
+
+ wrap.setProps({ selectedHierarchy: ['some', 'name', 'item1'] });
+
+ const { nodes } = wrap.state();
+
+ expect(nodes).toEqual({
+ 'another@namespace': true,
+ 'another@space@namespace': true,
+ 'another@space@20@component': true,
+ 'some@namespace': true,
+ 'some@name@namespace': true,
+ 'some@name@item1@component': true,
+ });
+ });
+
+ test('should not call setState when selectedHierarchy prop changes with the same value', () => {
+ const selectedHierarchy = ['another', 'space', '20'];
+ const data = createHierarchy(
+ [
+ { kind: 'some.name.item1', stories: ['a1', 'a2'] },
+ { kind: 'another.space.20', stories: ['b1', 'b2'] },
+ ],
+ '\\.'
+ );
+ const wrap = mount(
+
+ );
+
+ const setState = jest.fn();
+ wrap.instance().setState = setState;
+
+ wrap.setProps({ selectedHierarchy });
+
+ expect(setState).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('events', () => {
+ test('should call the onSelectStory prop when a kind is clicked', () => {
+ const data = createHierarchy([
+ { kind: 'a', stories: ['a1', 'a2'] },
+ { kind: 'b', stories: ['b1', 'b2'] },
+ ]);
+ const onSelectStory = jest.fn();
+ const wrap = mount(
+
+ );
+
+ const kind = wrap.find('a').filterWhere(el => el.text() === 'a').last();
+ kind.simulate('click');
+
+ expect(onSelectStory).toHaveBeenCalledWith('a', null);
+ });
+
+ test('should call the onSelectStory prop when a story is clicked', () => {
+ const data = createHierarchy([
+ { kind: 'a', stories: ['a1', 'a2'] },
+ { kind: 'b', stories: ['b1', 'b2'] },
+ ]);
+ const onSelectStory = jest.fn();
+ const wrap = mount(
+
+ );
+
+ const kind = wrap.find('a').filterWhere(el => el.text() === 'b1').last();
+ kind.simulate('click');
+
+ expect(onSelectStory).toHaveBeenCalledWith('b', 'b1');
+ });
+
+ test('should call the onSelectStory prop when a story is clicked - hierarchySeparator is defined', () => {
+ const data = createHierarchy(
+ [
+ { kind: 'some.name.item1', stories: ['a1', 'a2'] },
+ { kind: 'another.space.20', stories: ['b1', 'b2'] },
+ ],
+ '\\.'
+ );
+
+ const onSelectStory = jest.fn();
+ const wrap = mount(
+
+ );
+
+ 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');
+
+ expect(onSelectStory).toHaveBeenCalledWith('another.space.20', null);
+
+ wrap.find('a').filterWhere(el => el.text() === 'b2').last().simulate('click');
+
+ expect(onSelectStory).toHaveBeenCalledWith('another.space.20', 'b2');
+ });
+
+ test('should call the onSelectStory prop when a story is selected with enter key', () => {
+ const data = createHierarchy(
+ [
+ { kind: 'some.name.item1', stories: ['a1', 'a2'] },
+ { kind: 'another.space.20', stories: ['b1', 'b2'] },
+ ],
+ '\\.'
+ );
+
+ const onSelectStory = jest.fn();
+ const wrap = mount(
+
+ );
+
+ 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 });
+
+ 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
new file mode 100644
index 000000000000..b22049492ea2
--- /dev/null
+++ b/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_decorators.js
@@ -0,0 +1,97 @@
+import { decorators } from 'react-treebeard';
+import { IoChevronRight } from 'react-icons/lib/io';
+import React from 'react';
+import PropTypes from 'prop-types';
+
+function ToggleDecorator({ style }) {
+ const { height, width, arrow } = style;
+
+ return (
+
+ );
+}
+
+ToggleDecorator.propTypes = {
+ style: PropTypes.shape({
+ width: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ arrow: PropTypes.object.isRequired,
+ }).isRequired,
+};
+
+function ContainerDecorator(props) {
+ const { node } = props;
+
+ if (node.root) {
+ return null;
+ }
+
+ return
;
+}
+
+ContainerDecorator.propTypes = {
+ node: PropTypes.shape({
+ root: PropTypes.bool,
+ }).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);
+ }
+
+ render() {
+ const { style, node } = this.props;
+
+ const newStyleTitle = {
+ ...style.title,
+ };
+
+ if (!node.children || !node.children.length) {
+ newStyleTitle.fontSize = '13px';
+ }
+
+ return (
+
+ );
+ }
+ }
+
+ 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,
+ };
+}
diff --git a/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_node_type.js b/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_node_type.js
new file mode 100644
index 000000000000..13ba2a9acbb9
--- /dev/null
+++ b/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_node_type.js
@@ -0,0 +1,5 @@
+export default {
+ NAMESPACE: 'namespace',
+ COMPONENT: 'component',
+ STORY: 'story',
+};
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
new file mode 100644
index 000000000000..6d7da6ede213
--- /dev/null
+++ b/lib/ui/src/modules/ui/components/left_panel/stories_tree/tree_style.js
@@ -0,0 +1,77 @@
+import { baseFonts } from '../../theme';
+
+const toggleWidth = '24px';
+
+export default {
+ tree: {
+ base: {
+ listStyle: 'none',
+ margin: 0,
+ padding: 0,
+ fontFamily: baseFonts.fontFamily,
+ fontSize: '15px',
+ minWidth: '200px',
+ marginLeft: '-19px',
+ },
+ node: {
+ base: {
+ position: 'relative',
+ },
+ link: {
+ cursor: 'pointer',
+ position: 'relative',
+ padding: '0px 5px',
+ display: 'block',
+ },
+ activeLink: {
+ fontWeight: 'bold',
+ backgroundColor: '#EEE',
+ },
+ toggle: {
+ base: {
+ position: 'relative',
+ display: 'inline-block',
+ verticalAlign: 'top',
+ marginLeft: '-5px',
+ height: '24px',
+ width: toggleWidth,
+ },
+ wrapper: {
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ margin: '-12px 0 0 -4px',
+ },
+ height: 10,
+ width: 10,
+ arrow: {
+ fill: '#9DA5AB',
+ },
+ },
+ header: {
+ base: {
+ display: 'inline-block',
+ verticalAlign: 'top',
+ maxWidth: `calc(100% - ${toggleWidth})`,
+ },
+ connector: {
+ width: '2px',
+ height: '12px',
+ borderLeft: 'solid 2px black',
+ borderBottom: 'solid 2px black',
+ position: 'absolute',
+ top: '0px',
+ left: '-21px',
+ },
+ title: {
+ lineHeight: '24px',
+ verticalAlign: 'middle',
+ },
+ },
+ subtree: {
+ paddingLeft: '19px',
+ listStyle: 'none',
+ },
+ },
+ },
+};
diff --git a/lib/ui/src/modules/ui/containers/left_panel.js b/lib/ui/src/modules/ui/containers/left_panel.js
index 8191ba886e11..3f3dc11bfd8e 100755
--- a/lib/ui/src/modules/ui/containers/left_panel.js
+++ b/lib/ui/src/modules/ui/containers/left_panel.js
@@ -2,16 +2,27 @@ 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, resolveStoryHierarchy } 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, hierarchySeparator } = uiOptions;
+ const filteredStories = filters.storyFilter(
+ stories,
+ storyFilter,
+ selectedKind,
+ sortStoriesByKind
+ );
+
+ const storiesHierarchy = createHierarchy(filteredStories, hierarchySeparator);
+ const selectedHierarchy = resolveStoryHierarchy(selectedKind, hierarchySeparator);
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..8896e6ed983c 100755
--- a/lib/ui/src/modules/ui/containers/left_panel.test.js
+++ b/lib/ui/src/modules/ui/containers/left_panel.test.js
@@ -6,6 +6,7 @@ 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',
@@ -34,8 +35,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);
@@ -79,10 +83,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', () => {
@@ -122,10 +128,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/filters.js b/lib/ui/src/modules/ui/libs/filters.js
index 79d2ef7e9eae..cc327758b818 100755
--- a/lib/ui/src/modules/ui/libs/filters.js
+++ b/lib/ui/src/modules/ui/libs/filters.js
@@ -11,11 +11,27 @@ export function storyFilter(stories, filter, selectedKind, sortStoriesByKind) {
if (!stories) return null;
const sorted = sort(stories, sortStoriesByKind);
if (!filter) return sorted;
-
- return sorted.filter(kindInfo => {
- if (kindInfo.kind === selectedKind) return true;
+ return sorted.reduce((acc, kindInfo) => {
+ // Don't filter out currently selected filter
+ if (kindInfo.kind === selectedKind) return acc.concat(kindInfo);
const needle = filter.toLocaleLowerCase();
const hstack = kindInfo.kind.toLocaleLowerCase();
- return fuzzysearch(needle, hstack);
- });
+
+ // If a match is found in the story hierachy structure return kindInfo
+ if (fuzzysearch(needle, hstack)) return acc.concat(kindInfo);
+
+ // Now search at individual story level and filter results
+ const matchedStories = kindInfo.stories.filter(story => {
+ const storyHstack = story.toLocaleLowerCase();
+ return fuzzysearch(needle, storyHstack);
+ });
+
+ if (matchedStories.length)
+ return acc.concat({
+ kind: kindInfo.kind,
+ stories: matchedStories,
+ });
+
+ return acc;
+ }, []);
}
diff --git a/lib/ui/src/modules/ui/libs/filters.test.js b/lib/ui/src/modules/ui/libs/filters.test.js
index 638776289b2e..abfe36c21348 100755
--- a/lib/ui/src/modules/ui/libs/filters.test.js
+++ b/lib/ui/src/modules/ui/libs/filters.test.js
@@ -54,5 +54,45 @@ describe('manager.ui.libs.filters', () => {
expect(res).toEqual([stories[1], stories[2], stories[0]]);
});
+
+ test('should filter on story level', () => {
+ const stories = [
+ { kind: 'aa', stories: ['bb'] },
+ { kind: 'cc', stories: ['dd'] },
+ { kind: 'ee', stories: ['ff'] },
+ ];
+ const selectedKind = 'aa';
+ const res = storyFilter(stories, 'ff', selectedKind);
+
+ expect(res).toEqual([stories[0], stories[2]]);
+ });
+
+ test('should filter out unmatched stories at lowest level', () => {
+ const stories = [
+ { kind: 'aa', stories: ['bb'] },
+ { kind: 'cc', stories: ['dd'] },
+ { kind: 'ee', stories: ['ff', 'gg'] },
+ ];
+ const selectedKind = 'aa';
+ const res = storyFilter(stories, 'ff', selectedKind);
+
+ expect(res).toEqual([stories[0], { kind: 'ee', stories: ['ff'] }]);
+ });
+
+ test('should be case insensitive at tree level', () => {
+ const stories = [{ kind: 'aA', stories: ['bb'] }, { kind: 'cc', stories: ['dd'] }];
+ const selectedKind = 'aA';
+ const res = storyFilter(stories, 'aa', selectedKind);
+
+ expect(res).toEqual([stories[0]]);
+ });
+
+ test('should be case insensitive at story level', () => {
+ const stories = [{ kind: 'aa', stories: ['bb'] }, { kind: 'cc', stories: ['dd', 'eE'] }];
+ const selectedKind = 'aa';
+ const res = storyFilter(stories, 'ee', selectedKind);
+
+ expect(res).toEqual([stories[0], { kind: 'cc', stories: ['eE'] }]);
+ });
});
});
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..8f23bee2b683
--- /dev/null
+++ b/lib/ui/src/modules/ui/libs/hierarchy.js
@@ -0,0 +1,64 @@
+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,
+ name: 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 resolveStoryHierarchy(storyName = '', hierarchySeparator) {
+ if (!hierarchySeparator) {
+ return [storyName];
+ }
+
+ return storyName.split(new RegExp(hierarchySeparator));
+}
+
+export function createHierarchy(stories, hierarchySeparator) {
+ const hierarchyRoot = {
+ isNamespace: true,
+ namespaces: [],
+ name: '',
+ map: new Map(),
+ };
+
+ if (!stories) {
+ return hierarchyRoot;
+ }
+
+ const groupedStories = stories.map(story => {
+ const namespaces = resolveStoryHierarchy(story.kind, hierarchySeparator);
+
+ return {
+ namespaces,
+ name: namespaces[namespaces.length - 1],
+ ...story,
+ };
+ });
+
+ groupedStories.forEach(story => fillHierarchy(story.namespaces, hierarchyRoot, story));
+
+ return hierarchyRoot;
+}
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..f2a63d251741
--- /dev/null
+++ b/lib/ui/src/modules/ui/libs/hierarchy.test.js
@@ -0,0 +1,165 @@
+import { createHierarchy, resolveStoryHierarchy } 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: [],
+ name: '',
+ isNamespace: true,
+ map: new Map(),
+ });
+ });
+
+ test('should return root hierarchy node if stories are empty', () => {
+ const result = createHierarchy([]);
+
+ expect(result).toEqual({
+ namespaces: [],
+ name: '',
+ isNamespace: true,
+ map: new Map(),
+ });
+ });
+
+ test('should return flat hierarchy if hierarchySeparator 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 hierarchySeparator is defined', () => {
+ const stories = [
+ { kind: 'some.name.item1', stories: ['a1', 'a2'] },
+ { kind: 'another.space.20', stories: ['b1', 'b2'] },
+ ];
+
+ const result = createHierarchy(stories, '\\.');
+
+ const expected = new Map([
+ [
+ 'some',
+ [
+ {
+ name: 'some',
+ firstKind: 'some.name.item1',
+ isNamespace: true,
+ namespaces: ['some'],
+ map: new Map([
+ [
+ 'name',
+ [
+ {
+ name: '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',
+ [
+ {
+ name: 'another',
+ firstKind: 'another.space.20',
+ isNamespace: true,
+ namespaces: ['another'],
+ map: new Map([
+ [
+ 'space',
+ [
+ {
+ name: '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('resolveStoryHierarchy', () => {
+ test('should return array with initial namespace when hierarchySeparator is undefined', () => {
+ const result = resolveStoryHierarchy('some.name.item1');
+
+ expect(result).toEqual(['some.name.item1']);
+ });
+
+ test('should return array with separated namespaces when hierarchySeparator is defined', () => {
+ const result = resolveStoryHierarchy('some/name.item1', '\\.|\\/');
+
+ expect(result).toEqual(['some', 'name', 'item1']);
+ });
+ });
+});