diff --git a/App.js b/App.js index fbb0478..9678b90 100644 --- a/App.js +++ b/App.js @@ -1,10 +1,12 @@ import React from 'react'; -import {autobind} from 'core-decorators'; +import { Map } from 'immutable'; + +import { autobind } from 'core-decorators'; import R from 'ramda'; import classNames from 'classnames'; import Tree from './tree-component'; import './styles.less'; -import {deleteNodeWithChildren} from './helpers'; +import { deleteNodeWithChildren } from './helpers'; import TreeRow from './tree-row-component'; import RowRenderer from './row-renderer'; import nodes from './mock-data'; @@ -22,22 +24,22 @@ export default class App extends React.Component { state = { nodes - } + }; getRandomWord() { - return fetch('http://www.setgetgo.com/randomword/get.php?len=4') - .then((resp) => resp.text()); + return fetch( + 'http://www.setgetgo.com/randomword/get.php?len=4' + ).then(resp => resp.text()); } - @autobind - handleExpand(nodeId) { + @autobind handleExpand(nodeId) { const parentNode = this.state.nodes.byId[nodeId]; - if(parentNode.lazyLoad) { + if (parentNode.lazyLoad) { setTimeout(() => { const randomWords = []; this.getRandomWord() - .then((word) => randomWords.push(word)) + .then(word => randomWords.push(word)) .then(this.getRandomWord) - .then((word) => randomWords.push(word)) + .then(word => randomWords.push(word)) .then(() => { const newNode1 = { id: `${nodeId}.${randomWords[0]}`, @@ -54,7 +56,7 @@ export default class App extends React.Component { [nodeId]: R.assoc('childIds', [newNode1.id, newNode2.id]) }), R.assoc(newNode1.id, newNode1), - R.assoc(newNode2.id, newNode2), + R.assoc(newNode2.id, newNode2) )(this.state.nodes.byId); this.setState({ nodes: { @@ -62,13 +64,12 @@ export default class App extends React.Component { byId } }); - }) - }, MOCK_SERVER_TIME) + }); + }, MOCK_SERVER_TIME); } } - @autobind - handleClick(event, nodeId, parentId) { + @autobind handleClick(event, nodeId, parentId) { const classNames = event.target.className; if (R.contains('delete', classNames)) { this.setState({ @@ -77,17 +78,36 @@ export default class App extends React.Component { } } + @autobind shouldSelectNode(node) { + console.log( + 'shouldSelectNode ', + node.id, + '---', + this.tree.getSelectedNode() + ); + + if (!node || node.id === this.tree.getSelectedNode()) { + return false; // Prevent from deselecting the current node + } + return true; + } + render() { - const {nodes} = this.state; + const { nodes } = this.state; return ( -
+
+ nodes={nodes} + onClick={this.handleClick} + onExpand={this.handleExpand} + ref={c => { + this.tree = c && c.state; + console.log('Tree ', c); + }} + shouldSelectNode={this.shouldSelectNode} + rowRenderer={RowRenderer} + onCollapse={this.handleCollapse} + />
); } diff --git a/__tests__/row.test.js b/__tests__/row.test.js new file mode 100644 index 0000000..e52b7d6 --- /dev/null +++ b/__tests__/row.test.js @@ -0,0 +1,99 @@ +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { fromJS, List } from 'immutable'; +import toJSON from 'enzyme-to-json'; +import Row from '../row'; +import TitleComponent from '../title'; +import logger from '../utils/logger'; + +const mockData = { + byId: { + 1: { + id: '1', + name: 'One', + lazyLoad: true, + }, + 2: { + id: '2', + name: 'Two', + childIds: ['2.1', '2.2'], + }, + 2.1: { + id: '2.1', + name: 'Two.One', + }, + 2.2: { + id: '2.2', + name: 'Two.Two', + }, + }, +}; + +describe('', () => { + const states = { + expander: +, + collapser: -, + loader: ^, + }; + const requiredProps = { + expandedNodeIds: List(), + loadingNodeIds: List(), + nodes: fromJS(mockData), + nodeId: '2', + parentId: '2', + rowRenderer: TitleComponent, + renderedNodeIds: List(), + selectedNodeIds: List(), + }; + test('should load without crashing', () => { + const wrapper = shallow(); + expect(wrapper.length).toBe(1); + }); + + test('should have required default props', () => { + const wrapper = shallow(); + 'expander,collapser,loader,expanded,depth'.split(',').forEach((value) => { + expect(wrapper.instance().props[value]).toBeDefined(); + }); + }); + + test('should show expand icon if node have childs or is lazy loaded', () => {}); + + test('should have required methods passed as props', () => { + // onClick={this.handleClick} + // onToggle={this.handleToggle} + }); + + test('should call `toggle` function when clicked on expand icon', () => { + const onToggleFn = jest.fn(); + const wrapper = mount(); + // Row.prototype.mockToggleFn = jest.fn(); + // const spy = jest.spyOn(Row.prototype, "mockToggleFn"); + const ele = wrapper + .find(`#row-${requiredProps.nodeId}`) + .children('.node-header') + .children('.toggle-wrapper') + .find('a') + .simulate('click'); + // expect(spy).toHaveBeenCalled(); + expect(onToggleFn).toHaveBeenCalledWith(requiredProps.nodeId); + // expect(toJSON(wrapper)).toMatchSnapshot(); + }); + + test('should call `onClick` function to `delete node` with proper args when clicked', () => { + const onClick = jest.fn(); + const wrapper = mount(); + const ele = wrapper + .find(`#row-${requiredProps.nodeId}`) + .children('.node-header') + .children('.title-wrapper'); + const event = ele.simulate('click'); + logger.log('ele', ele.length); + // Unable to test this + // expect(onClick).toBeCalledWith(event,requiredProps.nodeId,requiredProps.parentId); + expect(onClick).toBeCalled(); + }); + test('should show loading indicator when node is lazily loaded', () => {}); + test('should select the node when user clicks on any node', () => {}); + test('should be able render the child nodes', () => {}); +}); diff --git a/__tests__/rowRenderer.test.js b/__tests__/rowRenderer.test.js new file mode 100644 index 0000000..b098741 --- /dev/null +++ b/__tests__/rowRenderer.test.js @@ -0,0 +1,42 @@ +import React from 'react'; +import RowRenderer from '../row-renderer'; +import logger from '../utils/logger'; +import toJson from 'enzyme-to-json'; +import TreeRowComonent from '../tree-row-component'; +import { shallow, mount } from 'enzyme'; + +describe('', () => { + test('should render without crashing', () => { + const mockData = { + id: 1, + name: 'Foo', + }; + const wrapper = shallow(); + expect(wrapper.length).toBe(1); + }); + + test('should allow user to rename the node', () => { + const mockData = { + id: 1, + name: 'Foo', + renameMode: true, + state: { + selected: false, + }, + }; + const wrapper = mount( + , + ); + expect(wrapper.find('.infinite-tree-rename-input').length).toBe(0); + expect(wrapper.find('.infinite-tree-title').length).toBe(1); + + wrapper.setProps({ node: mockData }); + expect(wrapper.find('.infinite-tree-rename-input').length).toBe(1); + expect(wrapper.find('.infinite-tree-title').length).toBe(0); + }); + + test('should have default props defined', () => { + const wrapper = shallow(); + expect(wrapper.instance().props.treeOptions).toEqual({}); + }); +}); diff --git a/__tests__/title.test.js b/__tests__/title.test.js new file mode 100644 index 0000000..b873f85 --- /dev/null +++ b/__tests__/title.test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Title from '../title'; + +describe('', () => { + test('should render without crashing', () => { + const mockData = { + name: 'Foo', + id: '1', + }; + const wrapper = mount(<Title node={mockData} />); + // wrapper.setProps({ node: mockData }); + expect(wrapper.length).toBe(1); + }); +}); diff --git a/__tests__/treeComponent.test.js b/__tests__/treeComponent.test.js new file mode 100644 index 0000000..26e1f02 --- /dev/null +++ b/__tests__/treeComponent.test.js @@ -0,0 +1,21 @@ +import React from 'react'; + +describe('<Tree/>', () => { + test('should pass', () => { + expect(1 === 1).toBe(true); + }); + + // test("should show collapse icon when node is expanded", () => { + // let $toggleWrapper = wrapper + // .find(`#row-${requiredProps.nodeId}`) + // .children(".toggle-wrapper"); + + // $toggleWrapper.find("a").simulate("click"); + // // expect($toggleWrapper.find("a").html()).toEqual(states.collapser); + // }); + + // test("should call collapse function when clicked on collapse icon", () => {}); + // test("should show loading indicator when node is lazily loaded", () => {}); + // test("should select the node when user clicks on any node", () => {}); + // test("should be able render the child nodes", () => {}); +}); diff --git a/__tests__/treeRow.test.js b/__tests__/treeRow.test.js new file mode 100644 index 0000000..a7b3751 --- /dev/null +++ b/__tests__/treeRow.test.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import toJSON from 'enzyme-to-json'; +import TreeRowComponent from '../tree-row-component'; +import logger from '../utils/logger'; + +describe('<TreeRowComponent/>', () => { + test('should load without crashing', () => {}); + + test('should add appropriate class whenever a node is selected', () => { + const mockData = { state: {} }; + const wrapper = shallow(<TreeRowComponent node={mockData} />); + let component = wrapper.find('.infinite-tree-item'); + let classNames = component.props().className.split(' '); + + expect(classNames).not.toContain('infinite-tree-selected'); + + wrapper.setProps({ + node: Object.assign({}, mockData, { state: { selected: true } }), + }); + + component = wrapper.find('.infinite-tree-item'); + classNames = component.props().className.split(' '); + + expect(classNames).toContain('infinite-tree-selected'); + }); +}); diff --git a/helpers.js b/helpers.js index 1df581e..d0f6998 100644 --- a/helpers.js +++ b/helpers.js @@ -13,16 +13,66 @@ export const removeNodeWithChildren = R.curry((nodesById, id) => { export const getNodeIdsToDelete = (byId, id) => { if (byId[id].childIds && byId[id].childIds.length) { - return R.reduce((currentNodes, nodeId) => - R.concat(currentNodes, getNodeIdsToDelete(byId, nodeId)), [id], byId[id].childIds); + return R.reduce( + (currentNodes, nodeId) => + R.concat(currentNodes, getNodeIdsToDelete(byId, nodeId)), + [id], + byId[id].childIds, + ); } return [id]; }; -export const deleteNodeWithChildren = (nodes, id) => { - const nodesToDelete = getNodeIdsToDelete(nodes.byId, id); - return R.evolve({ - byId: R.pickBy(node => !R.contains(node.id, nodesToDelete)), - rootIds: R.filter(R.complement(R.equals(id))), - }, nodes); +/** + * Filter out garbage references from node's childIds + */ +const deleteReferencesFromChildIds = R.curry((parentId, nodesToDelete) => + R.pipe( + R.tap(console.log), + R.over( + R.lensProp(parentId), + R.evolve({ + childIds: R.reject(value => R.contains(value, nodesToDelete)), + }), + ), + ), +); + +/** + * Deletes the node from `nodes` object + * @param {string} nodeId + * @param {array} nodesToDelete + * @param {object} nodes + */ +const deleteNodeById = (nodeId, nodesToDelete, nodes) => + R.evolve( + { + byId: R.pickBy(node => !R.contains(node.id, nodesToDelete)), + rootIds: R.reject(R.contains(nodeId)), + }, + nodes, + ); + +/** + * Wrapper around `deleteReferencesFromChildIds` for + * removing the nodes references which got deleted. + * @param {string} parentId + * @param {array} nodesToDelete + * @param {object} nodes + */ +const removeIds = (parentId, nodesToDelete, nodes) => + R.evolve( + { + byId: deleteReferencesFromChildIds(parentId, nodesToDelete), + rootIds: R.identity, + }, + nodes, + ); + +export const deleteNodeWithChildren = (nodes, nodeId, parentId) => { + const nodesToDelete = getNodeIdsToDelete(nodes.byId, nodeId); + return R.pipe( + R.partial(deleteNodeById, [nodeId, nodesToDelete]), + R.partial(removeIds, [parentId, nodesToDelete]), + )(nodes); }; diff --git a/index.html b/index.html index 02c72f6..b2d9bb6 100644 --- a/index.html +++ b/index.html @@ -1,15 +1,18 @@ <!DOCTYPE html> <html> + <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <title> + + Infinite Tree +
-
+ \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..b97ca56 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + moduleFileExtensions: ['js'], + testPathIgnorePatterns: ['/node_modules/'], + verbose: true, +}; diff --git a/package.json b/package.json index 081390b..f82ddd2 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "main.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "jest --config jest.config.js --coverage", "start": "concurrently --kill-others \"webpack-dev-server\"" }, "author": "", @@ -39,12 +39,17 @@ "babel-preset-react": "^6.16.0", "babel-preset-stage-0": "^6.16.0", "concurrently": "^3.1.0", + "enzyme": "^2.8.2", + "enzyme-to-json": "^1.5.1", "eslint": "^3.10.2", "eslint-config-airbnb": "^14.0.0", "eslint-plugin-import": "^2.2.0", "eslint-plugin-jsx-a11y": "^4.0.0", "eslint-plugin-react": "^6.7.1", + "jest": "^20.0.4", "less": "^2.7.1", + "react-addons-test-utils": "^15.5.1", + "sinon": "^2.3.2", "webpack": "2.4.1", "webpack-dev-server": "2.4.2" } diff --git a/row-renderer.js b/row-renderer.js index 96f2447..b153d71 100644 --- a/row-renderer.js +++ b/row-renderer.js @@ -2,40 +2,42 @@ import React, { PropTypes } from 'react'; import classNames from 'classnames'; import TreeRow from './tree-row-component'; -const RowRendererComponent = (props) => { +const RowRendererComponent = props => { const { node, treeOptions } = props; - const { - id, name, type, renameMode, contextMenu = true, - } = node; + const { id, name, type, renameMode, contextMenu = true } = node; const { deleteNode } = treeOptions; - const label = renameMode ? - () : {name}; + type='text' + /> + : {name}; - const className = classNames('infinite-tree-closed', { 'context-menu-button': contextMenu }, 'pull-right'); + const className = classNames( + 'infinite-tree-closed', + { 'context-menu-button': contextMenu }, + 'pull-right' + ); return ( - + {label} - D - ); + D + + ); }; RowRendererComponent.propTypes = { node: PropTypes.shape().isRequired, - treeOptions: PropTypes.shape(), + treeOptions: PropTypes.shape() }; RowRendererComponent.defaultProps = { treeOptions: {}, + node: {} }; export default RowRendererComponent; diff --git a/row.js b/row.js index 7ff33a4..9a0f158 100644 --- a/row.js +++ b/row.js @@ -1,6 +1,6 @@ import React from 'react'; import Title from './title'; -import {autobind} from 'core-decorators'; +import { autobind } from 'core-decorators'; import R from 'ramda'; export default class Row extends React.Component { @@ -10,45 +10,55 @@ export default class Row extends React.Component { expanded: React.PropTypes.bool, name: React.PropTypes.string, node: React.PropTypes.object, - rowRenderer: React.PropTypes.func, + rowRenderer: React.PropTypes.func }; static defaultProps = { - expander: +, - collapser: -, + expander: , + collapser: , loader: ^, expanded: false, - depth: 0, - } + depth: 0 + }; constructor(props) { super(props); } - @autobind - handleExpand() { + @autobind handleExpand() { if (typeof this.props.children === 'function') { - return this.props.children(); + return this.props.children(); } return this.setState({ - expanded: true + expanded: true }); } - @autobind - handleToggle(e) { + @autobind handleToggle(e) { this.props.onToggle(this.props.nodeId); e.stopPropagation(); } - @autobind - handleClick(event) { - this.props.onClick(event, this.props.nodeId, this.props.parentId); - event.stopPropagation(); + @autobind handleClick(event) { + this.props.onClick(event, this.props.nodeId, this.props.parentId); + event.stopPropagation(); } render() { - const {expander, collapser, nodeId, depth, nodes, selectedNodeIds, expandedNodeIds, loadingNodeIds, onToggle, onClick, loader, rowRenderer} = this.props; + const { + expander, + collapser, + nodeId, + depth, + nodes, + selectedNodeIds, + expandedNodeIds, + loadingNodeIds, + onToggle, + onClick, + loader, + rowRenderer + } = this.props; const node = nodes.get('byId').get(nodeId); const expanded = expandedNodeIds.indexOf(node.get('id')) >= 0; const internalLoading = loadingNodeIds.indexOf(node.get('id')) >= 0; @@ -56,48 +66,66 @@ export default class Row extends React.Component { const lazyLoad = node.get('lazyLoad'); const internalOrExternalLoading = loading || internalLoading; - const expand = !expanded && (lazyLoad || (!expanded && ((node.get('childIds') - && !node.get('childIds').isEmpty())))) && + const expand = + !expanded && + (lazyLoad || (node.get('childIds') && !node.get('childIds').isEmpty())) && {expander}; - const collapse = expanded && node.get('childIds') && node.get('childIds').size && - {collapser} + const collapse = + expanded && + node.get('childIds') && + node.get('childIds').size && + {collapser}; + const TitleRenderer = rowRenderer; const nodeState = { - expanded, - selected: selectedNodeIds.contains(nodeId), - depth, - } + expanded, + selected: selectedNodeIds.contains(nodeId), + depth + }; const nodeWithState = { - ...node.toJS(), - state: nodeState - } + ...node.toJS(), + state: nodeState + }; const toggler = expand || collapse; return ( -
-
+
+
+
{!internalOrExternalLoading ? toggler : loader} +
+
+ {expand + ?