<main></main>
body { 
  font-size: 24px;
}
button {
  font-size: 24px;
}
const ADD = "add";
const TOGGLE = "toggle";

function act_toggle(path) {
  return { type: TOGGLE, payload: path };
}

function act_add(text, path) {
  return { type: ADD, payload: { path: Immutable.fromJS(path), text: text }};  
}

var initialState = Immutable.fromJS({
  text: "root", 
  childNodes: [],
  expanded: false,
  path: [],
});

function pathForUpdate(ipath) {
  var path = ipath.toJS();
  var keys = _.range(path.length).map(() => 'childNodes' );
  return _.flatten(_.zip(keys, path));
}

function generateNode(data, idx) {
  if ( ! _.isNumber(idx) ) { throw "Invalid Index: " + idx }
  
  return Immutable.fromJS({
    text: data.text,
    childNodes: [],
    expanded: false,
    path: data.path.push(idx),
  });
}

function reducer(state = Immutable.fromJS(initialState), action) {
  var path;
  switch(action.type) {
    case ADD:
      path = pathForUpdate(action.payload.path);
      return state.updateIn(path, 
                            (node) => node.update('childNodes', 
                                                  (list) => list.push(generateNode(action.payload, list.size))) );
    case TOGGLE:
      path = pathForUpdate(action.payload);
      return state.updateIn(path, (node) => node.update('expanded', (val) => ! val ));
      
    default:
      return state;
  }
}

var Node = React.createClass({
  toggle: function(path) {
    this.props.toggle(path);
  },
  shouldComponentUpdate: function(nextProps, nextState) {
    return ! Immutable.is(nextProps.item, this.props.item);
  },
  divStyle: function(item) {
    var indent    = item.get('path').size;
    return {
      marginLeft: (indent * 10) + "px"
    };
  },  
  renderChild: function(item) {
    return <Node item={item} toggle={this.props.toggle} />
  },  
  render: function() {
    var item = this.props.item
    var disabled  = item.get('childNodes').size == 0;
    var collapsed = ! item.get('expanded');
    var text      = disabled ? "" : collapsed ? "+" : "-";
    
    return <div style={this.divStyle(item)}>
          <button disabled={disabled} 
                  onClick={this.toggle.bind(this, item.get('path'))}>{text}</button>
          <span>{item.get('text')}</span>
          {item.get('expanded') ? item.get('childNodes').map(this.renderChild, this) : false }
      </div>
  }
});

var App = React.createClass({
  getInitialState: function() {
    return { item: this.props.store.getState() };
  },
  componentDidMount: function() {
    this.props.store.subscribe(this.storeUpdated);
  },
  storeUpdated: function() {
    this.setState({ item: this.props.store.getState()});
  },
  toggle: function(path) {
    this.props.store.dispatch(act_toggle(path));
  },
  render: function() {
    return <div>
        <Node  item={this.state.item} toggle={this.toggle} />
    </div>
  }
});

var store = Redux.createStore(reducer);

store.dispatch(act_add("0", []));
store.dispatch(act_add("1", []));
store.dispatch(act_add("2", []));
store.dispatch(act_add("0_0", [0]));
store.dispatch(act_add("0_1", [0]));
store.dispatch(act_add("0_2", [0]));
store.dispatch(act_add("1_0", [1]));
store.dispatch(act_add("1_1", [1]));
store.dispatch(act_add("1_2", [1]));
store.dispatch(act_add("1_3", [1]));
store.dispatch(act_add("1_4", [1]));
store.dispatch(act_add("0_0_0", [0,0]));
store.dispatch(act_add("0_0_1", [0,0]));
store.dispatch(act_add("0_0_2", [0,0]));
store.dispatch(act_add("0_0_3", [0,0]));
store.dispatch(act_add("0_0_4", [0,0]));



ReactDOM.render(<App store={store} />, document.querySelector('main'));
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/0.14.2/react-with-addons.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react/0.14.2/react-dom.js
  3. https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js
  4. https://cdnjs.cloudflare.com/ajax/libs/redux/3.0.4/redux.min.js
  5. https://cdnjs.cloudflare.com/ajax/libs/immutable/3.7.5/immutable.min.js
  6. https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js