]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-7147 keep current state in the url
authorStas Vilchik <vilchiks@gmail.com>
Fri, 18 Dec 2015 17:43:53 +0000 (18:43 +0100)
committerStas Vilchik <vilchiks@gmail.com>
Mon, 21 Dec 2015 14:39:29 +0000 (15:39 +0100)
server/sonar-web/package.json
server/sonar-web/src/main/js/apps/code/actions/index.js
server/sonar-web/src/main/js/apps/code/app.js
server/sonar-web/src/main/js/apps/code/components/Code.js
server/sonar-web/src/main/js/apps/code/components/SourceViewer.js
server/sonar-web/src/main/js/apps/code/reducers/index.js
server/sonar-web/src/main/js/apps/code/store/configureStore.js
server/sonar-web/src/main/js/main/app.js
server/sonar-web/tests/apps/code/components-test.js
server/sonar-web/tests/apps/code/store-test.js

index a698746b3d2946954970e7eefa9694716d7cce42..3c2e132b09e06a0f3f3a91b7ebe183613f33670f 100644 (file)
@@ -42,6 +42,7 @@
     "gulp-util": "3.0.6",
     "handlebars": "^2.0.0",
     "handlebars-loader": "^1.1.4",
+    "history": "^1.13.1",
     "imports-loader": "^0.6.5",
     "isparta": "^4.0.0",
     "jquery": "2.1.4",
     "react-addons-test-utils": "0.14.2",
     "react-dom": "0.14.2",
     "react-redux": "^4.0.1",
+    "react-router": "^1.0.2",
     "react-select": "1.0.0-beta6",
     "redux": "^3.0.5",
     "redux-logger": "^2.2.1",
+    "redux-simple-router": "^1.0.1",
     "redux-thunk": "^1.0.2",
     "script-loader": "^0.6.1",
     "sinon": "1.15.4",
index 135c77758ab48517df13e890daf31eed1d0b16d5..dcf7e47d55480d087ef84587cf736d521807b39f 100644 (file)
@@ -1,6 +1,8 @@
 import _ from 'underscore';
+import { pushPath } from 'redux-simple-router';
 
 import { getChildren, getComponent } from '../../../api/components';
+import { getComponentNavigation } from '../../../api/nav';
 
 
 const METRICS = [
@@ -9,6 +11,7 @@ const METRICS = [
   'violations',
   'duplicated_lines_density'
 ];
+
 const METRICS_WITH_COVERAGE = [
   ...METRICS,
   'coverage',
@@ -19,60 +22,85 @@ const METRICS_WITH_COVERAGE = [
 
 export const INIT = 'INIT';
 export const BROWSE = 'BROWSE';
-export const RECEIVE_COMPONENTS = 'RECEIVE_COMPONENTS';
-export const SHOW_SOURCE = 'SHOW_SOURCE';
+export const START_FETCHING = 'START_FETCHING';
+export const STOP_FETCHING = 'STOP_FETCHING';
 
 
-export function requestComponents (baseComponent) {
+export function initComponentAction (component, breadcrumbs = []) {
   return {
-    type: BROWSE,
-    baseComponent
+    type: INIT,
+    component,
+    breadcrumbs
   };
 }
 
-
-export function receiveComponents (baseComponent, components) {
+export function browseAction (component, children = [], breadcrumbs = []) {
   return {
-    type: RECEIVE_COMPONENTS,
-    baseComponent,
-    components
+    type: BROWSE,
+    component,
+    children,
+    breadcrumbs
   };
 }
 
+export function startFetching () {
+  return { type: START_FETCHING };
+}
 
-export function showSource (component) {
-  return {
-    type: SHOW_SOURCE,
-    component
-  };
+export function stopFetching () {
+  return { type: STOP_FETCHING };
 }
 
 
-function fetchChildren (dispatch, getState, baseComponent) {
-  dispatch(requestComponents(baseComponent));
+function getPath (componentKey) {
+  return '/' + encodeURIComponent(componentKey);
+}
 
-  const { coverageMetric } = getState();
-  const metrics = [...METRICS, coverageMetric];
+function retrieveComponentBase (componentKey, candidate) {
+  return candidate ?
+      Promise.resolve(candidate) :
+      getComponent(componentKey, METRICS_WITH_COVERAGE);
+}
 
-  return getChildren(baseComponent.key, metrics)
-      .then(components => _.sortBy(components, 'name'))
-      .then(components => dispatch(receiveComponents(baseComponent, components)));
+function retrieveComponentChildren (componentKey, candidate) {
+  return candidate && candidate.children ?
+      Promise.resolve(candidate.children) :
+      getChildren(componentKey, METRICS_WITH_COVERAGE);
 }
 
+function retrieveComponentBreadcrumbs (componentKey, candidate) {
+  return candidate && candidate.breadcrumbs ?
+      Promise.resolve(candidate.breadcrumbs) :
+      getComponentNavigation(componentKey).then(navigation => navigation.breadcrumbs);
+}
 
-export function initComponent (baseComponent) {
-  return (dispatch, getState) => {
-    return getComponent(baseComponent.key, METRICS_WITH_COVERAGE)
-        .then(component => fetchChildren(dispatch, getState, component));
-  };
+function retrieveComponent (componentKey, bucket) {
+  const candidate = _.findWhere(bucket, { key: componentKey });
+  return Promise.all([
+    retrieveComponentBase(componentKey, candidate),
+    retrieveComponentChildren(componentKey, candidate),
+    retrieveComponentBreadcrumbs(componentKey, candidate)
+  ]);
 }
 
+export function initComponent (componentKey, breadcrumbs) {
+  return dispatch => {
+    dispatch(startFetching());
+    return getComponent(componentKey, METRICS_WITH_COVERAGE)
+        .then(component => dispatch(initComponentAction(component, breadcrumbs)))
+        .then(() => dispatch(stopFetching()));
+  };
+}
 
-export function fetchComponents (baseComponent) {
+export function browse (componentKey) {
   return (dispatch, getState) => {
-    const { fetching } = getState();
-    if (!fetching) {
-      return fetchChildren(dispatch, getState, baseComponent);
-    }
+    const { bucket } = getState();
+    dispatch(startFetching());
+    return retrieveComponent(componentKey, bucket)
+        .then(([component, children, breadcrumbs]) => {
+          dispatch(browseAction(component, children, breadcrumbs));
+        })
+        .then(() => dispatch(pushPath(getPath(componentKey))))
+        .then(() => dispatch(stopFetching()));
   };
 }
index c16b4aa3057d2b4f10eca5c9c7cb2f98329df43b..ce973644f6903cb0c243dfcd71abbdd851aba137 100644 (file)
@@ -1,18 +1,33 @@
 import React from 'react';
 import { render } from 'react-dom';
 import { Provider } from 'react-redux';
+import { Router, Route } from 'react-router';
+import { createHashHistory } from 'history';
+import { syncReduxAndRouter } from 'redux-simple-router';
 
 import Code from './components/Code';
 import configureStore from './store/configureStore';
 
 
 const store = configureStore();
+const history = createHashHistory({
+  queryKey: false
+});
+
+syncReduxAndRouter(history, store);
+
 
+window.sonarqube.appStarted.then(({ el, component }) => {
+  const CodeWithComponent = () => {
+    return <Code component={component}/>;
+  };
 
-window.sonarqube.appStarted.then(({ el, ...other }) => {
   render(
       <Provider store={store}>
-        <Code {...other}/>
+        <Router history={history}>
+          <Route path="/" component={CodeWithComponent}/>
+          <Route path="/:path" component={CodeWithComponent}/>
+        </Router>
       </Provider>,
       document.querySelector(el));
 });
index ff463ebf5d01cf23e5862206ca4fb2f2bcf031f5..7f364cf3939668c69c7128624017ba7a66cc219e 100644 (file)
@@ -5,34 +5,31 @@ import { connect } from 'react-redux';
 import Components from './Components';
 import Breadcrumbs from './Breadcrumbs';
 import SourceViewer from './SourceViewer';
-import { initComponent, fetchComponents, showSource } from '../actions';
+import { initComponent, browse } from '../actions';
 import { TooltipsContainer } from '../../../components/mixins/tooltips-mixin';
 
 
 class Code extends Component {
   componentDidMount () {
-    const { dispatch, component } = this.props;
-    dispatch(initComponent(component));
+    const { dispatch, component, routing } = this.props;
+    const selectedKey = (routing.path && decodeURIComponent(routing.path.substr(1))) || component.key;
+    dispatch(initComponent(component.key, component.breadcrumbs))
+        .then(() => dispatch(browse(selectedKey)));
   }
 
   componentWillReceiveProps (nextProps) {
-    if (nextProps.component !== this.props.component) {
-      const { dispatch, component } = this.props;
-      dispatch(initComponent(component));
+    if (nextProps.routing !== this.props.routing) {
+      const { dispatch, routing, component, fetching } = nextProps;
+      if (!fetching) {
+        const selectedKey = (routing.path && decodeURIComponent(routing.path.substr(1))) || component.key;
+        dispatch(browse(selectedKey));
+      }
     }
   }
 
-  hasSourceCode (component) {
-    return component.qualifier === 'FIL' || component.qualifier === 'UTS';
-  }
-
   handleBrowse (component) {
     const { dispatch } = this.props;
-    if (this.hasSourceCode(component)) {
-      dispatch(showSource(component));
-    } else {
-      dispatch(fetchComponents(component));
-    }
+    dispatch(browse(component.key));
   }
 
   render () {
@@ -83,5 +80,6 @@ class Code extends Component {
   }
 }
 
-
-export default connect(state => state)(Code);
+export default connect(state => {
+  return Object.assign({ routing: state.routing }, state.current);
+})(Code);
index f207e4a0faf6f35a10fbbaa6687e5f99e042f80a..09457d8dd06df889a046bd70fd7a34683a0957cd 100644 (file)
@@ -8,6 +8,14 @@ export default class SourceViewer extends Component {
     this.renderSourceViewer();
   }
 
+  shouldComponentUpdate (nextProps) {
+    return nextProps.component.uuid !== this.props.component.uuid;
+  }
+
+  componentWillUpdate () {
+    this.destroySourceViewer();
+  }
+
   componentDidUpdate () {
     this.renderSourceViewer();
   }
index 3a86e706e8dd8b1c3d094b16e02c7a7030f27a3d..08b74a6710e3cac2996ef3564ccbfe5d8ba5d607 100644 (file)
@@ -1,76 +1,12 @@
 import _ from 'underscore';
-import { combineReducers } from 'redux';
 
-import { BROWSE, RECEIVE_COMPONENTS, SHOW_SOURCE } from '../actions';
+import { INIT, BROWSE, START_FETCHING, STOP_FETCHING } from '../actions';
 
 
-export function fetching (state = false, action) {
-  switch (action.type) {
-    case BROWSE:
-      return true;
-    case RECEIVE_COMPONENTS:
-      return false;
-    default:
-      return state;
-  }
-}
-
-
-export function baseComponent (state = null, action) {
-  switch (action.type) {
-    case RECEIVE_COMPONENTS:
-      return action.baseComponent;
-    default:
-      return state;
-  }
-}
-
-
-export function components (state = null, action) {
-  switch (action.type) {
-    case RECEIVE_COMPONENTS:
-      return action.components;
-    default:
-      return state;
-  }
+function hasSourceCode (component) {
+  return component.qualifier === 'FIL' || component.qualifier === 'UTS';
 }
 
-
-export function breadcrumbs (state = [], action) {
-  switch (action.type) {
-    case BROWSE:
-      const existedIndex = state.findIndex(b => b.key === action.baseComponent.key);
-      let nextBreadcrumbs;
-
-      if (existedIndex === -1) {
-        // browse deeper
-        nextBreadcrumbs = [...state, action.baseComponent];
-      } else {
-        // use breadcrumbs
-        nextBreadcrumbs = [...state.slice(0, existedIndex + 1)];
-      }
-
-      return nextBreadcrumbs;
-    case SHOW_SOURCE:
-      return [...state, action.component];
-    default:
-      return state;
-  }
-}
-
-
-export function sourceViewer (state = null, action) {
-  switch (action.type) {
-    case BROWSE:
-      return null;
-    case SHOW_SOURCE:
-      return action.component;
-    default:
-      return state;
-  }
-}
-
-
 function selectCoverageMetric (component) {
   const coverage = _.findWhere(component.msr, { key: 'coverage' });
   const itCoverage = _.findWhere(component.msr, { key: 'it_coverage' });
@@ -85,25 +21,66 @@ function selectCoverageMetric (component) {
   }
 }
 
+function merge (components, candidate) {
+  const found = _.findWhere(components, { key: candidate.key });
+  const newEntry = Object.assign({}, found, candidate);
+  return [...(_.without(components, found)), newEntry];
+}
 
-export function coverageMetric (state = null, action) {
+
+export const initialState = {
+  fetching: false,
+  baseComponent: null,
+  components: null,
+  breadcrumbs: null,
+  sourceViewer: null,
+  coverageMetric: null,
+  baseBreadcrumbs: []
+};
+
+
+export function current (state = initialState, action) {
   switch (action.type) {
+    case INIT:
+      const coverageMetric = selectCoverageMetric(action.component);
+      const baseBreadcrumbs = action.breadcrumbs.length > 1 ? _.initial(action.breadcrumbs) : [];
+
+      return { ...state, coverageMetric, baseBreadcrumbs };
     case BROWSE:
-      return state !== null ? state : selectCoverageMetric(action.baseComponent);
+      const baseComponent = hasSourceCode(action.component) ? null : action.component;
+      const components = hasSourceCode(action.component) ? null : _.sortBy(action.children, 'name');
+      const baseBreadcrumbsLength = state.baseBreadcrumbs.length;
+      const breadcrumbs = action.breadcrumbs.slice(baseBreadcrumbsLength);
+      const sourceViewer = hasSourceCode(action.component) ? action.component : null;
+
+      return { ...state, baseComponent, components, breadcrumbs, sourceViewer };
+    case START_FETCHING:
+      return { ...state, fetching: true };
+    case STOP_FETCHING:
+      return { ...state, fetching: false };
     default:
       return state;
   }
 }
 
 
-const rootReducer = combineReducers({
-  fetching,
-  baseComponent,
-  components,
-  breadcrumbs,
-  sourceViewer,
-  coverageMetric
-});
-
-
-export default rootReducer;
+export function bucket (state = [], action) {
+  switch (action.type) {
+    case INIT:
+      return merge(state, action.component);
+    case BROWSE:
+      const candidate = Object.assign({}, action.component, {
+        children: action.children,
+        breadcrumbs: action.breadcrumbs
+      });
+      const nextState = merge(state, candidate);
+      return action.children.reduce((currentState, nextComponent) => {
+        const nextComponentWidthBreadcrumbs = Object.assign({}, nextComponent, {
+          breadcrumbs: [...action.breadcrumbs, nextComponent]
+        });
+        return merge(currentState, nextComponentWidthBreadcrumbs);
+      }, nextState);
+    default:
+      return state;
+  }
+}
index 821c23f5ae0e968f15f5448a5e9a11e53a2c962c..4b9f858a89347f7b10e18c3c756170cb5c9d4268 100644 (file)
@@ -1,11 +1,11 @@
-import { createStore, applyMiddleware } from 'redux';
+import { createStore, applyMiddleware, combineReducers } from 'redux';
 import thunk from 'redux-thunk';
 import createLogger from 'redux-logger';
-
-import rootReducer from '../reducers';
+import { routeReducer } from 'redux-simple-router';
+import { current, bucket } from '../reducers';
 
 const logger = createLogger({
-  predicate: () => process.env.NODE_ENV === 'development'
+  predicate: () => process.env.NODE_ENV !== 'production'
 });
 
 const createStoreWithMiddleware = applyMiddleware(
@@ -13,7 +13,12 @@ const createStoreWithMiddleware = applyMiddleware(
     logger
 )(createStore);
 
+const reducer = combineReducers({
+  routing: routeReducer,
+  current,
+  bucket
+});
 
 export default function configureStore () {
-  return createStoreWithMiddleware(rootReducer);
+  return createStoreWithMiddleware(reducer);
 }
index d902b888ea364d4201427d31d0700d778e907b6d..ab58da869aba3ba1e5ceb2e92e6ca7e84bd6d632 100644 (file)
@@ -37,7 +37,8 @@ function prepareAppOptions (navResponse) {
         id: navResponse.component.uuid,
         key: navResponse.component.key,
         name: navResponse.component.name,
-        qualifier: _.last(navResponse.component.breadcrumbs).qualifier
+        qualifier: _.last(navResponse.component.breadcrumbs).qualifier,
+        breadcrumbs: navResponse.component.breadcrumbs
       };
     }
   }
index 5048b2fa8bc6c28307ed92cf7bd94a8c7205e219..7639d3716f8fdd66319d2f07579737ed35c38b65 100644 (file)
@@ -26,13 +26,14 @@ const measures = [
   { key: 'ncloc', val: 9757 }
 ];
 const exampleComponent = {
+  uuid: 'A1',
   key: 'A',
   name: 'AA',
   qualifier: 'TRK',
   msr: measures
 };
-const exampleComponent2 = { key: 'B' };
-const exampleComponent3 = { key: 'C' };
+const exampleComponent2 = { uuid: 'B2', key: 'B' };
+const exampleComponent3 = { uuid: 'C3', key: 'C' };
 const exampleOnBrowse = sinon.spy();
 
 
index cf8532fe75526f88af49f8e9a9df54a809b29705..9d3445cf0af2da41fa7ddd4e3608b8407cf1dccd 100644 (file)
 import { expect } from 'chai';
 
+import { current, bucket, initialState } from '../../../src/main/js/apps/code/reducers';
 import {
-    fetching,
-    baseComponent,
-    components,
-    breadcrumbs,
-    sourceViewer,
-    coverageMetric
-} from '../../../src/main/js/apps/code/reducers';
-import {
-    requestComponents,
-    receiveComponents,
-    showSource
+    initComponentAction,
+    browseAction,
+    startFetching,
+    stopFetching
 } from '../../../src/main/js/apps/code/actions';
 
 
 const exampleComponent = { key: 'A' };
+const exampleComponents = [
+  { key: 'B' },
+  { key: 'C' }
+];
 
 
 describe('Code :: Store', () => {
   //describe('action creators');
 
   describe('reducers', () => {
-    describe('fetching', () => {
-      it('should be initially false', () => {
-        expect(fetching(undefined, {}))
-            .to.equal(false);
-      });
-
-      it('should be true after requesting components', () => {
-        expect(fetching(false, requestComponents()))
-            .to.equal(true);
-      });
-
-      it('should be false after receiving components', () => {
-        expect(fetching(true, receiveComponents({}, [])))
-            .to.equal(false);
-      });
-    });
-
-    describe('baseComponent', () => {
-      it('should not be set after requesting components', () => {
-        const component = {};
-        expect(baseComponent(null, requestComponents(component)))
-            .to.equal(null);
-      });
-
-      it('should be set after receiving components', () => {
-        const component = {};
-        expect(baseComponent(null, receiveComponents(component, [])))
-            .to.equal(component);
-      });
-    });
+    describe('current', () => {
+      describe('fetching', () => {
+        it('should be set to true', () => {
+          expect(current({ ...initialState, fetching: false }, startFetching()).fetching)
+              .to.equal(true);
+        });
 
-    describe('components', () => {
-      it('should be set after receiving components', () => {
-        const list = [exampleComponent];
-        expect(components(null, receiveComponents({}, list)))
-            .to.equal(list);
+        it('should be false', () => {
+          expect(current({ ...initialState, fetching: true }, stopFetching()).fetching)
+              .to.equal(false);
+        });
       });
-    });
+      describe('baseComponent', () => {
+        it('should be set', () => {
+          const component = {};
+          expect(current(initialState, browseAction(component)).baseComponent)
+              .to.equal(component);
+        });
 
-    describe('breadcrumbs', () => {
-      it('should push new component on BROWSE', () => {
-        const stateBefore = [];
-        const stateAfter = [exampleComponent];
-        expect(breadcrumbs(stateBefore, requestComponents(exampleComponent)))
-            .to.deep.equal(stateAfter);
+        it('should not be set for components with source code', () => {
+          const file = { qualifier: 'FIL' };
+          expect(current(initialState, browseAction(file, exampleComponents)).baseComponent)
+              .to.be.null;
+          const test = { qualifier: 'UTS' };
+          expect(current(initialState, browseAction(test, exampleComponents)).baseComponent)
+              .to.be.null;
+        });
       });
+      describe('components', () => {
+        it('should be set', () => {
+          const component = {};
+          expect(current(initialState, browseAction(component, exampleComponents)).components)
+              .to.deep.equal(exampleComponents);
+        });
 
-      it('should push new component on SHOW_SOURCE', () => {
-        const stateBefore = [];
-        const stateAfter = [exampleComponent];
-        expect(breadcrumbs(stateBefore, showSource(exampleComponent)))
-            .to.deep.equal(stateAfter);
-      });
+        it('should sort components by name', () => {
+          const component = {};
+          const componentsBefore = [
+            { key: 'A', name: 'B' },
+            { key: 'B', name: 'A' }
+          ];
+          const componentsAfter = [
+            { key: 'B', name: 'A' },
+            { key: 'A', name: 'B' }
+          ];
+          expect(current(initialState, browseAction(component, componentsBefore)).components)
+              .to.deep.equal(componentsAfter);
+        });
 
-      it('should cut the tail', () => {
-        const stateBefore = [{ key: 'B' }, exampleComponent, { key: 'C' }];
-        const stateAfter = [{ key: 'B' }, exampleComponent];
-        expect(breadcrumbs(stateBefore, requestComponents(exampleComponent)))
-            .to.deep.equal(stateAfter);
+        it('should not be set for components with source code', () => {
+          const file = { qualifier: 'FIL' };
+          expect(current(initialState, browseAction(file, exampleComponents)).components)
+              .to.be.null;
+          const test = { qualifier: 'UTS' };
+          expect(current(initialState, browseAction(test, exampleComponents)).components)
+              .to.be.null;
+        });
       });
-    });
+      describe('breadcrumbs', () => {
+        it('should be set', () => {
+          expect(current(initialState, browseAction(exampleComponent, [], exampleComponents)).breadcrumbs)
+              .to.deep.equal(exampleComponents);
+        });
 
-    describe('sourceViewer', () => {
-      it('should be set on SHOW_SOURCE', () => {
-        expect(sourceViewer(null, showSource(exampleComponent)))
-            .to.equal(exampleComponent);
+        it('should respect baseBreadcrumbs', () => {
+          const baseBreadcrumbs = [{ key: 'BASE1' }];
+          const breadcrumbsBefore = [{ key: 'BASE1' }, { key: 'BASE2' }, { key: 'C' }];
+          const breadcrumbsAfter = [{ key: 'BASE2' }, { key: 'C' }];
+          expect(current(
+              { ...initialState, baseBreadcrumbs },
+              browseAction(exampleComponent, [], breadcrumbsBefore)).breadcrumbs
+          ).to.deep.equal(breadcrumbsAfter);
+        });
       });
+      describe('sourceViewer', () => {
+        it('should be set for components with source code', () => {
+          const file = { qualifier: 'FIL' };
+          expect(current(initialState, browseAction(file, exampleComponents)).sourceViewer)
+              .to.equal(file);
+          const test = { qualifier: 'UTS' };
+          expect(current(initialState, browseAction(test, exampleComponents)).sourceViewer)
+              .to.equal(test);
+        });
 
-      it('should be unset on BROWSE', () => {
-        expect(sourceViewer(exampleComponent, requestComponents({})))
-            .to.equal(null);
+        it('should not be set for components without source code', () => {
+          const project = { qualifier: 'TRK' };
+          expect(current(initialState, browseAction(project, exampleComponents)).sourceViewer)
+              .to.be.null;
+          const unknown = {};
+          expect(current(initialState, browseAction(unknown, exampleComponents)).sourceViewer)
+              .to.be.null;
+        });
       });
-
       describe('coverageMetric', () => {
-        it('should be initially null', () => {
-          expect(coverageMetric(undefined, {}))
-              .to.equal(null);
-        });
-
         it('should be set to "coverage"', () => {
           const componentWithCoverage = {
             ...exampleComponent,
@@ -109,7 +122,7 @@ describe('Code :: Store', () => {
             ]
           };
 
-          expect(coverageMetric(null, requestComponents(componentWithCoverage)))
+          expect(current(initialState, initComponentAction(componentWithCoverage)).coverageMetric)
               .to.equal('coverage');
         });
 
@@ -121,7 +134,7 @@ describe('Code :: Store', () => {
             ]
           };
 
-          expect(coverageMetric(null, requestComponents(componentWithCoverage)))
+          expect(current(initialState, initComponentAction(componentWithCoverage)).coverageMetric)
               .to.equal('it_coverage');
         });
 
@@ -135,7 +148,7 @@ describe('Code :: Store', () => {
             ]
           };
 
-          expect(coverageMetric(null, requestComponents(componentWithCoverage)))
+          expect(current(initialState, initComponentAction(componentWithCoverage)).coverageMetric)
               .to.equal('overall_coverage');
         });
 
@@ -145,22 +158,106 @@ describe('Code :: Store', () => {
             msr: []
           };
 
-          expect(coverageMetric(null, requestComponents(componentWithCoverage)))
+          expect(current(initialState, initComponentAction(componentWithCoverage)).coverageMetric)
               .to.equal('it_coverage');
         });
+      });
+      describe('baseBreadcrumbs', () => {
+        it('should be empty', () => {
+          const component = { key: 'A' };
+          const breadcrumbs = [{ key: 'A' }];
 
-        it('should not be reset after set once', () => {
-          const componentWithCoverage = {
-            ...exampleComponent,
-            msr: [
-              { key: 'coverage', val: 13 }
-            ]
-          };
+          expect(current(initialState, initComponentAction(component, breadcrumbs)).baseBreadcrumbs)
+              .to.have.length(0);
+        });
 
-          expect(coverageMetric('overall_coverage', requestComponents(componentWithCoverage)))
-              .to.equal('overall_coverage');
+        it('should set baseBreadcrumbs', () => {
+          const component = { key: 'A' };
+          const breadcrumbs = [{ key: 'BASE' }, { key: 'A' }];
+
+          expect(current(initialState, initComponentAction(component, breadcrumbs)).baseBreadcrumbs)
+              .to.have.length(1);
         });
       });
     });
+    describe('bucket', () => {
+      it('should add initial component', () => {
+        expect(bucket([], initComponentAction(exampleComponent)))
+            .to.deep.equal([exampleComponent]);
+      });
+
+      it('should add browsed component', () => {
+        const componentBefore = { key: 'A' };
+        const childrenBefore = [{ key: 'B' }];
+        const breadcrumbsBefore = [{ key: 'A' }];
+
+        const bucketAfter = [
+          { key: 'A', breadcrumbs: [{ key: 'A' }], children: [{ key: 'B' }] },
+          { key: 'B', breadcrumbs: [{ key: 'A' }, { key: 'B' }] }
+        ];
+
+        expect(bucket([], browseAction(componentBefore, childrenBefore, breadcrumbsBefore)))
+            .to.deep.equal(bucketAfter);
+      });
+
+      it('should merge new components', () => {
+        const componentBefore = { key: 'A' };
+        const childrenBefore = [{ key: 'B' }];
+        const breadcrumbsBefore = [{ key: 'A' }];
+
+        const bucketBefore = [
+          { key: 'A' },
+          { key: 'B' }
+        ];
+
+        const bucketAfter = [
+          {
+            key: 'A',
+            breadcrumbs: [{ key: 'A' }],
+            children: [{ key: 'B' }]
+          },
+          {
+            key: 'B',
+            breadcrumbs: [{ key: 'A' }, { key: 'B' }]
+          }
+        ];
+
+        expect(bucket(bucketBefore, browseAction(componentBefore, childrenBefore, breadcrumbsBefore)))
+            .to.deep.equal(bucketAfter);
+      });
+
+      it('should work twice in a row', () => {
+        const componentA = { key: 'A' };
+        const childrenA = [{ key: 'B' }];
+        const breadcrumbsA = [{ key: 'A' }];
+
+        const componentB = { key: 'B' };
+        const childrenB = [{ key: 'C' }];
+        const breadcrumbsB = [{ key: 'A' }, { key: 'B' }];
+
+        const bucketAfter = [
+          {
+            key: 'A',
+            breadcrumbs: [{ key: 'A' }],
+            children: [{ key: 'B' }]
+          },
+          {
+            key: 'B',
+            breadcrumbs: [{ key: 'A' }, { key: 'B' }],
+            children: [{ key: 'C' }]
+          },
+          {
+            key: 'C',
+            breadcrumbs: [{ key: 'A' }, { key: 'B' }, { key: 'C' }]
+          }
+        ];
+
+        const afterFirstPass = bucket([], browseAction(componentA, childrenA, breadcrumbsA));
+        const afterSecondPass = bucket(afterFirstPass, browseAction(componentB, childrenB, breadcrumbsB));
+
+        expect(afterSecondPass)
+            .to.deep.equal(bucketAfter);
+      });
+    });
   });
 });