diff --git a/package.json b/package.json index a19ef48..c9ac712 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ }, "dependencies": { "curry": "^1.2.0", - "redux-actions": "^0.8.0" + "redux-actions": "^0.8.0", + "uniloc": "^0.2.0" }, "devDependencies": { "babel-cli": "^6.2.0", @@ -43,6 +44,7 @@ "eslint-config-ecliptic": "^1.3.0", "jsx-chai": "^1.1.1", "mocha": "^2.3.3", + "proxyquire": "^1.7.3", "sinon": "^1.17.2", "sinon-chai": "^2.8.0" } diff --git a/src/actions.js b/src/actions.js index 7db97a5..5598f8d 100644 --- a/src/actions.js +++ b/src/actions.js @@ -1,6 +1,5 @@ import {createAction} from 'redux-actions' -import {INIT_ROUTER, URL_CHANGED} from './index' - -export const initRouter = createAction(INIT_ROUTER) +import {URL_CHANGED, NAVIGATE} from './index' export const urlChanged = createAction(URL_CHANGED) +export const navigate = createAction(NAVIGATE) diff --git a/src/index.js b/src/index.js index a01d117..09e9ff3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,2 +1,3 @@ export const INIT_ROUTER = 'INIT_ROUTER' export const URL_CHANGED = 'URL_CHANGED' +export const NAVIGATE = 'NAVIGATE' diff --git a/src/init.js b/src/init.js index fa90187..b13ffcf 100644 --- a/src/init.js +++ b/src/init.js @@ -1,19 +1,17 @@ -import * as actions from './actions' +import {configureRouter} from './router' +import {getUrl} from './utils' +import {urlChanged} from './actions' -export function getUrl(universal = false, store) { - if (universal) { - return store.getState().router.url - } - return window.location.pathname + window.location.search -} - -export function handlePopState(store) { +export const handlePopState = store => () => { const url = getUrl() - store.dispatch(actions.urlChanged({url, source: 'popState'})) + store.dispatch(urlChanged({url, source: 'popState'})) } export default function init(store, routes, aliases) { const url = getUrl() - store.dispatch(actions.initRouter({url, routes, aliases})) - window.onpopstate = handlePopState + + configureRouter(routes, aliases) + + store.dispatch(urlChanged({url, source: 'init'})) + window.onpopstate = handlePopState(store) } diff --git a/src/reducer.js b/src/reducer.js index db78523..118295a 100644 --- a/src/reducer.js +++ b/src/reducer.js @@ -1,9 +1,26 @@ -import {URL_CHANGED} from '../src/index' -import handleActions from 'redux-actions' -import uniloc from 'uniloc' +import {getUrl} from './utils' +import {handleActions} from 'redux-actions' +import {URL_CHANGED, NAVIGATE} from '../src/index' +import router from '../src/router' + +export function routeState(url) { + return {route: router.lookup(url), url} +} export default handleActions({ [URL_CHANGED]: (state, action) => { - + return Object.assign({}, state, routeState(action.payload.url)) + }, + [NAVIGATE]: (state, action) => { + if (getUrl() !== action.payload.url) { + if (action.payload.silent) { + history.replaceState({}, null, action.payload.url) + } else { + history.pushState({}, null, action.payload.url) + } + + return Object.assign({}, state, routeState(action.payload.url)) + } + return state }, }) diff --git a/src/router.js b/src/router.js new file mode 100644 index 0000000..0843c29 --- /dev/null +++ b/src/router.js @@ -0,0 +1,9 @@ +import uniloc from 'uniloc' + +let router = uniloc() + +export function configureRouter(routes, aliases) { + router = uniloc(routes, aliases) +} + +export default router diff --git a/src/utils.js b/src/utils.js index e69de29..8311b9d 100644 --- a/src/utils.js +++ b/src/utils.js @@ -0,0 +1,6 @@ +export function getUrl(universal = false, store) { + if (universal) { + return store.getState().router.url + } + return window.location.pathname + window.location.search +} diff --git a/test/test-actions.js b/test/test-actions.js deleted file mode 100644 index d9ff9fe..0000000 --- a/test/test-actions.js +++ /dev/null @@ -1,12 +0,0 @@ -import chai, {expect} from 'chai' -import jsxChai from 'jsx-chai' - -chai.use(jsxChai) - -describe('actions', () => { - - describe('navigateTo()', () => { - - }) - -}) diff --git a/test/test-init.js b/test/test-init.js index 5e781bc..6cbab5d 100644 --- a/test/test-init.js +++ b/test/test-init.js @@ -1,8 +1,14 @@ -import init, {getUrl, handlePopState} from '../src/init' import chai, {expect} from 'chai' +import proxyquire from 'proxyquire' import sinon from 'sinon' import sinonChai from 'sinon-chai' +const configureSpy = sinon.spy() + +const init = proxyquire('../src/init', { + './router': {configureRouter: configureSpy}, +}) + chai.use(sinonChai) describe('init', () => { @@ -20,24 +26,8 @@ describe('init', () => { delete global.window }) - describe('getUrl()', () => { - - it('pulls the url from window.location', () => { - const result = getUrl() - - expect(result).to.equal('/space/unicorn?lasers=marshmallow') - }) - - it('pulls the url from the state if this is a universal app', () => { - const store = { - getState: () => ({router: {url: '/space/unicorn?rainbows=delivered'}}), - } - - const result = getUrl(true, store) - - expect(result).to.equal('/space/unicorn?rainbows=delivered') - }) - + afterEach(() => { + configureSpy.reset() }) describe('handlePopState()', () => { @@ -50,7 +40,7 @@ describe('init', () => { type: 'URL_CHANGED', } - handlePopState(store, {}) + init.handlePopState(store)({}) expect(store.dispatch).to.have.been.calledWith(expected) }) @@ -77,20 +67,26 @@ describe('init', () => { delete window.onpopstate }) - it('dispatches an initRouter event', () => { + it('configures the router with the provided routes and aliases', () => { + init.default(store, routes, aliases) + + expect(configureSpy).to.have.been.calledWith(routes, aliases) + }) + + it('dispatches an urlChanged event', () => { const expected = { - payload: {url: '/space/unicorn?lasers=marshmallow', routes, aliases}, - type: 'INIT_ROUTER', + payload: {url: '/space/unicorn?lasers=marshmallow', source: 'init'}, + type: 'URL_CHANGED', } - init(store, routes, aliases) + init.default(store, routes, aliases) expect(store.dispatch).to.have.been.calledWith(expected) }) it('attaches an event handler to window.onpopstate', () => { - init(store, routes, aliases) - expect(window).to.have.property('onpopstate').and.equal(handlePopState) + init.default(store, routes, aliases) + expect(window).to.have.property('onpopstate').and.be.a('function') }) }) diff --git a/test/test-reducer.js b/test/test-reducer.js index 382126e..9ac1bfc 100644 --- a/test/test-reducer.js +++ b/test/test-reducer.js @@ -1,7 +1,114 @@ -import {expect} from 'chai' +import * as actions from '../src/actions' +import chai, {expect} from 'chai' +import reducer, {routeState} from '../src/reducer' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) describe('reducer', () => { - + before(() => { + global.window = { + location: { + pathname: '/space/unicorn', + search: '?lasers=marshmallow', + }, + } + }) + + after(() => { + delete global.window + }) + + describe('routeState()', () => { + + it('packages the route details up for the state', () => { + const expected = { + route: { + name: undefined, + options: {'bigger-on-the-inside': 'true'}, + }, + url: '/the/tardis?bigger-on-the-inside=true', + } + + const result = routeState('/the/tardis?bigger-on-the-inside=true') + + expect(result).to.deep.equal(expected) + }) + + }) + + describe('URL_CHANGED', () => { + + it('handles url changed events', () => { + const action = actions.urlChanged({ + url: '/my/awesome/url?awesome=true', + source: 'test', + }) + const expected = routeState('/my/awesome/url?awesome=true') + + const result = reducer({}, action) + + expect(result).to.deep.equal(expected) + }) + + }) + + describe('NAVIGATE', () => { + + before(() => { + global.history = { + pushState: sinon.spy(), + replaceState: sinon.spy(), + } + }) + + afterEach(() => { + global.history.pushState.reset() + global.history.replaceState.reset() + }) + + after(() => { + delete global.history + }) + + it('handles navigate events', () => { + const action = actions.navigate({url: '/my/awesome/url?awesome=true'}) + const expected = routeState('/my/awesome/url?awesome=true') + + const result = reducer({}, action) + + expect(result).to.deep.equal(expected) + }) + + it('creates a new history entry with pushState', () => { + const action = actions.navigate({url: '/into/the/tardis'}) + + reducer({}, action) + + expect(global.history.pushState).to.have.been.calledWith({}, null, '/into/the/tardis') + expect(global.history.replaceState).to.not.have.been.called + }) + + it('uses replaceState if silent is true', () => { + const action = actions.navigate({url: '/into/the/tardis', silent: true}) + + reducer({}, action) + + expect(global.history.pushState).to.not.have.been.called + expect(global.history.replaceState).to.have.been.calledWith({}, null, '/into/the/tardis') + }) + + it('doesn\'t create a new history entry if the user is already on that location', () => { + const action = actions.navigate({url: '/space/unicorn?lasers=marshmallow'}) + + reducer({}, action) + + expect(global.history.pushState).to.not.have.been.called + expect(global.history.replaceState).to.not.have.been.called + }) + + }) }) diff --git a/test/test-router.js b/test/test-router.js new file mode 100644 index 0000000..36f499d --- /dev/null +++ b/test/test-router.js @@ -0,0 +1,33 @@ +import chai, {expect} from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import proxyquire from 'proxyquire' + +chai.use(sinonChai) + +const unilocSpy = sinon.spy() + +const router = proxyquire('../src/router', { + 'uniloc': unilocSpy, +}) + +describe('router', () => { + + afterEach(() => { + unilocSpy.reset() + }) + + describe('configureRouter()', () => { + + it('refreshes the router instance with new routes and aliases', () => { + const routes = [] + const aliases = {} + + router.configureRouter(routes, aliases) + + expect(unilocSpy).to.have.been.calledWith(routes, aliases) + }) + + }) + +}) diff --git a/test/test-utils.js b/test/test-utils.js index e69de29..05218e9 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -0,0 +1,39 @@ +import {getUrl} from '../src/utils' +import {expect} from 'chai' + +describe('utils', () => { + + describe('getUrl()', () => { + + before(() => { + global.window = { + location: { + pathname: '/space/unicorn', + search: '?lasers=marshmallow', + }, + } + }) + + after(() => { + delete global.window + }) + + it('pulls the url from window.location', () => { + const result = getUrl() + + expect(result).to.equal('/space/unicorn?lasers=marshmallow') + }) + + it('pulls the url from the state if this is a universal app', () => { + const store = { + getState: () => ({router: {url: '/space/unicorn?rainbows=delivered'}}), + } + + const result = getUrl(true, store) + + expect(result).to.equal('/space/unicorn?rainbows=delivered') + }) + + }) + +})