diff --git a/static_src/actions/router_actions.js b/static_src/actions/router_actions.js new file mode 100644 index 00000000..303c91d7 --- /dev/null +++ b/static_src/actions/router_actions.js @@ -0,0 +1,16 @@ +import AppDispatcher from '../dispatcher.js'; +import { routerActionTypes } from '../constants'; + +const routerActions = { + navigate(component, props) { + AppDispatcher.handleViewAction({ + type: routerActionTypes.NAVIGATE, + data: { + component, + props + } + }); + } +}; + +export default routerActions; diff --git a/static_src/components/header.jsx b/static_src/components/header.jsx index 608df5e1..c08ebcb5 100644 --- a/static_src/components/header.jsx +++ b/static_src/components/header.jsx @@ -38,7 +38,7 @@ export default class Header extends React.Component {
- + +

Not Found

; + +export default NotFound; diff --git a/static_src/components/router/route_provider.jsx b/static_src/components/router/route_provider.jsx new file mode 100644 index 00000000..eeded0ff --- /dev/null +++ b/static_src/components/router/route_provider.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import RouterStore from '../../stores/router_store.js'; +import Loading from '../loading.jsx'; + +class RouteProvider extends React.Component { + constructor() { + super(); + + this.state = RouterStore.component; + + this.onChange = this.onChange.bind(this); + } + + componentDidMount() { + RouterStore.addChangeListener(this.onChange); + } + + shouldComponentUpdate(nextProps, nextState) { + if (this.state === nextState) { + return false; + } + + return true; + } + + componentWillUnmount() { + RouterStore.removeChangeListener(this.onChange); + } + + onChange() { + this.setState({ ...RouterStore.component }); + } + + render() { + const { component: Component, props } = this.state; + return Component ? : ; + } +} + +export default RouteProvider; diff --git a/static_src/constants.js b/static_src/constants.js index 3d21c950..eee0c63e 100644 --- a/static_src/constants.js +++ b/static_src/constants.js @@ -338,6 +338,10 @@ const routeActionTypes = keymirror({ ROUTE_ERROR: null }); +const routerActionTypes = keymirror({ + NAVIGATE: null +}); + const domainActionTypes = keymirror({ DOMAIN_FETCH: null, DOMAIN_RECEIVED: null @@ -366,6 +370,7 @@ export { pageActionTypes, quotaActionTypes, routeActionTypes, + routerActionTypes, spaceActionTypes, serviceActionTypes, userActionTypes diff --git a/static_src/main.js b/static_src/main.js index ccb8290f..10605793 100644 --- a/static_src/main.js +++ b/static_src/main.js @@ -4,22 +4,44 @@ import './css/main.css'; import './img/dashboard-uaa-icon.jpg'; import 'cloudgov-style/img/favicon.ico'; +import React from 'react'; +import ReactDOM from 'react-dom'; + import axios from 'axios'; import { Router } from 'director'; import { trackPageView } from './util/analytics.js'; import routes, { checkAuth, clearErrors, notFound } from './routes'; -const meta = document.querySelector('meta[name="gorilla.csrf.Token"]'); -if (meta) { - axios.defaults.headers.common['X-CSRF-Token'] = meta.content; -} - -const router = new Router(routes); -router.configure({ - async: true, - before: [clearErrors, checkAuth], - notfound: notFound, - on: () => trackPageView(window.location.hash) -}); -router.init('/'); +import MainContainer from './components/main_container.jsx'; +import RouteProvider from './components/router/route_provider.jsx'; + +const initCSRFHeader = metaTag => { + if (metaTag) { + axios.defaults.headers.common['X-CSRF-Token'] = metaTag.content; + } +}; + +const cRouter = { + run(routeConfig, renderEl) { + const router = new Router(routeConfig); + router.configure({ + async: true, + before: [clearErrors, checkAuth], + notfound: notFound, + on: () => { + trackPageView(window.location.hash); + } + }); + + ReactDOM.render( + + + , renderEl); + + router.init('/'); + } +}; + +initCSRFHeader(document.querySelector('meta[name="gorilla.csrf.Token"]')); +cRouter.run(routes, document.querySelector('.js-app')); diff --git a/static_src/routes.js b/static_src/routes.js index 61a2720c..b1840c52 100644 --- a/static_src/routes.js +++ b/static_src/routes.js @@ -1,6 +1,3 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; - import activityActions from './actions/activity_actions.js'; import AppContainer from './components/app_container.jsx'; import appActions from './actions/app_actions.js'; @@ -10,7 +7,7 @@ import Loading from './components/loading.jsx'; import Login from './components/login.jsx'; import loginActions from './actions/login_actions'; import LoginStore from './stores/login_store'; -import MainContainer from './components/main_container.jsx'; +import NotFound from './components/not_found.jsx'; import orgActions from './actions/org_actions.js'; import Overview from './components/overview_container.jsx'; import OrgContainer from './components/org_container.jsx'; @@ -24,15 +21,12 @@ import { appHealth } from './util/health.js'; import { entityHealth } from './constants.js'; import windowUtil from './util/window'; import userActions from './actions/user_actions.js'; - -// TODO this is hard to stub since we query it at module load time. It should -// be passed in or something. -const mainEl = document.querySelector('.js-app'); +import routerActions from './actions/router_actions.js'; const MAX_OVERVIEW_SPACES = 10; export function login(next) { - ReactDOM.render(, mainEl); + routerActions.navigate(Login); next(); } @@ -56,11 +50,7 @@ export function overview(next) { return Promise.all(fetches); }) .then(pageActions.loadSuccess, pageActions.loadError); - - ReactDOM.render( - - , mainEl); - + routerActions.navigate(Overview); next(); } @@ -75,11 +65,7 @@ export function org(orgGuid, next) { userActions.changeCurrentlyViewedType('org_users'); userActions.fetchOrgUsers(orgGuid); userActions.fetchOrgUserRoles(orgGuid); - ReactDOM.render( - - - , mainEl); - + routerActions.navigate(OrgContainer); next(); } @@ -97,13 +83,7 @@ export function space(orgGuid, spaceGuid, next) { userActions.fetchSpaceUserRoles(spaceGuid); orgActions.fetch(orgGuid); serviceActions.fetchAllServices(orgGuid); - - ReactDOM.render( - - - , mainEl); + routerActions.navigate(SpaceContainer, { currentPage: 'apps' }); next(); } @@ -126,10 +106,7 @@ export function app(orgGuid, spaceGuid, appGuid, next) { routeActions.fetchRoutesForApp(appGuid); serviceActions.fetchAllInstances(spaceGuid); serviceActions.fetchServiceBindings(); - ReactDOM.render( - - - , mainEl); + routerActions.navigate(AppContainer); next(); } @@ -169,9 +146,11 @@ export function checkAuth(...args) { // Just in case something goes wrong, don't leave the user hanging. Show // a delayed loading indicator to give them a hint. Hopefully the // redirect is quick and they never see the loader. - ReactDOM.render( - - , mainEl); + routerActions.navigate(Loading, { + text: 'Redirecting to login', + loadingDelayMS: 3000, + style: 'inline' + }); // Stop the routing next(false); @@ -199,8 +178,7 @@ export function notFound(next) { orgActions.changeCurrentOrg(); spaceActions.changeCurrentSpace(); appActions.changeCurrentApp(); - - ReactDOM.render(

Not Found

, mainEl); + routerActions.navigate(NotFound); next(); } diff --git a/static_src/stores/router_store.js b/static_src/stores/router_store.js new file mode 100644 index 00000000..7dd58fae --- /dev/null +++ b/static_src/stores/router_store.js @@ -0,0 +1,30 @@ +import BaseStore from './base_store.js'; +import { routerActionTypes } from '../constants.js'; + +class RouterStore extends BaseStore { + constructor() { + super(); + + this.routeComponent = {}; + this.subscribe(() => this.registerToActions.bind(this)); + } + + registerToActions(action) { + const { type, data } = action; + + switch (type) { + case routerActionTypes.NAVIGATE: + this.routeComponent = Object.assign({}, { ...data }); + this.emitChange(); + break; + default: + break; + } + } + + get component() { + return this.routeComponent; + } +} + +export default new RouterStore(); diff --git a/static_src/test/unit/actions/router_actions.spec.js b/static_src/test/unit/actions/router_actions.spec.js new file mode 100644 index 00000000..ceb55e33 --- /dev/null +++ b/static_src/test/unit/actions/router_actions.spec.js @@ -0,0 +1,30 @@ +import '../../global_setup'; +import React from 'react'; +import { assertAction, setupViewSpy } from '../helpers'; +import routerActions from '../../../actions/router_actions'; +import { routerActionTypes } from '../../../constants'; + +describe('routerActions', () => { + let sandbox; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('navigate()', () => { + it('dispatches `NAVIGATE` action and passes a component and props', () => { + const props = { some: 'data' }; + const component = () =>
; + const expected = { component, props }; + const spy = setupViewSpy(sandbox); + + routerActions.navigate(component, props); + + assertAction(spy, routerActionTypes.NAVIGATE, expected); + }); + }); +}); diff --git a/static_src/test/unit/components/router/route_provider.spec.jsx b/static_src/test/unit/components/router/route_provider.spec.jsx new file mode 100644 index 00000000..33a84130 --- /dev/null +++ b/static_src/test/unit/components/router/route_provider.spec.jsx @@ -0,0 +1,29 @@ +import '../../../global_setup'; +import React from 'react'; +import RouteProvider from '../../../../components/router/route_provider.jsx'; +import Loading from '../../../../components/loading.jsx'; +import { shallow } from 'enzyme'; +import RouterStore from '../../../../stores/router_store'; + +describe('', () => { + beforeEach(() => { + RouterStore.routeComponent = {}; + }); + + it('sets a default state from the RouteStore', () => { + const component = () =>
; + const props = { some: 'data' }; + RouterStore.routeComponent = Object.assign({}, { component, props }); + const wrapper = shallow(); + + expect(wrapper.state().component).toBe(component); + expect(wrapper.state().props.some).toBe('data'); + expect(wrapper.find(component).length).toBe(1); + }); + + it('renders a loading component as a fallback', () => { + const wrapper = shallow(); + + expect(wrapper.find(Loading).length).toBe(1); + }); +}); diff --git a/static_src/test/unit/helpers.js b/static_src/test/unit/helpers.js index 34031b31..c15f11d3 100644 --- a/static_src/test/unit/helpers.js +++ b/static_src/test/unit/helpers.js @@ -24,7 +24,8 @@ export function assertAction(spy, type, params) { let actionInfo = spy.getCall(0).args[0]; expect(actionInfo.type).toEqual(type); for (let param in params) { - expect(actionInfo[param]).toEqual(params[param]); + const datum = 'data' in actionInfo ? actionInfo.data[param] : actionInfo[param]; + expect(datum).toEqual(params[param]); } } diff --git a/static_src/test/unit/routes.spec.js b/static_src/test/unit/routes.spec.js index 4cdb6486..0dc1148e 100644 --- a/static_src/test/unit/routes.spec.js +++ b/static_src/test/unit/routes.spec.js @@ -1,6 +1,6 @@ import '../global_setup.js'; -import ReactDOM from 'react-dom'; +import routerActions from '../../actions/router_actions'; import errorActions from '../../actions/error_actions'; import loginActions from '../../actions/login_actions'; import LoginStore from '../../stores/login_store'; @@ -126,7 +126,7 @@ describe('routes', function () { beforeEach(function (done) { next = sandbox.spy(done); - sandbox.stub(ReactDOM, 'render'); + sandbox.stub(routerActions, 'navigate'); sandbox.stub(windowUtil, 'redirect'); loginActions.fetchStatus.returns(Promise.resolve({ status: 'unauthorized' })); @@ -142,7 +142,7 @@ describe('routes', function () { }); it('renders a loader', function () { - expect(ReactDOM.render).toHaveBeenCalledOnce(); + expect(routerActions.navigate).toHaveBeenCalledOnce(); }); it('does not fetch page data', function () { diff --git a/static_src/test/unit/stores/router_store.spec.js b/static_src/test/unit/stores/router_store.spec.js new file mode 100644 index 00000000..66e449da --- /dev/null +++ b/static_src/test/unit/stores/router_store.spec.js @@ -0,0 +1,38 @@ +import '../../global_setup'; +import React from 'react'; +import sinon from 'sinon'; +import AppDispatcher from '../../../dispatcher'; +import RouterStore from '../../../stores/router_store'; +import { routerActionTypes } from '../../../constants'; + +describe('RouterStore', () => { + const dispatchNavigationAction = component => + AppDispatcher.handleViewAction({ + type: routerActionTypes.NAVIGATE, + data: { + component + } + }); + + describe('instantiation', () => { + it('sets its routeComponent property', () => { + expect(RouterStore.routeComponent).toEqual(jasmine.any(Object)); + }); + }); + + describe('responding to actions', () => { + const component = () =>
; + + it('sets the current route information passed by `NAVIGATE` action', () => { + dispatchNavigationAction(component); + expect(RouterStore.component.component).toEqual(component); + }); + + it('emits a change event when it responds to an action', () => { + const spy = sinon.spy(RouterStore, 'emitChange'); + dispatchNavigationAction(component); + + expect(spy).toHaveBeenCalledOnce(); + }); + }); +});