diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a16c54..b7b3368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. * Create tests for the current components. * Activate auto deployment to gh-pages. * [#4] Add this CHANGELOG.md file. +* [#1] Option page. + * Add main menu drawer. + * Add router (History API) integration. + +### Changed +* [#1] Replace MDL with MDC. ## [0.1.0] - 2017-10-06 ### Added diff --git a/package.json b/package.json index 5102031..9811ee7 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ ], "coverageThreshold": { "global": { - "branches": 80 + "branches": 90 } } }, diff --git a/public/Template/Application.html.tpl b/public/Template/Application.html.tpl index c590e3e..980d150 100644 --- a/public/Template/Application.html.tpl +++ b/public/Template/Application.html.tpl @@ -19,11 +19,13 @@ />
-
-
- Page: {this.state.history.page}
- Component: {this.state.currentComponent} -
+
+
+                Root: {this.state.history.root}
+ Page: {this.state.history.page}
+ Path: {this.state.pathname}
+
+ {this.state.currentComponent}
diff --git a/src/Application/Application.js b/src/Application/Application.js index d920e94..79f2b39 100644 --- a/src/Application/Application.js +++ b/src/Application/Application.js @@ -1,7 +1,7 @@ import React from 'react'; import Component from '../Shared/LiveJSX'; import Menu from '../Menu'; -import Router from './Router'; +import Router from '../Shared/Router'; /** * Root Application. @@ -16,6 +16,18 @@ class Application extends Component { return '/Template/Application.html.tpl'; } + /** + * Setup routes of the application. + * + * @returns {Object} + */ + static get routes() { + return { + index: '/', + settings: '/settings/' // option page + }; + } + /** * Constructor. * @@ -35,8 +47,10 @@ class Application extends Component { { currentComponent:
, history: { - page: 'none' - } + page: 'none', + root: '' + }, + pathname: '' } ); @@ -46,6 +60,8 @@ class Application extends Component { }; this.registeredButtonHandler = []; + + this.boundPathChange = this.onPathChange.bind(this); } onMainButtonClick() { @@ -57,7 +73,23 @@ class Application extends Component { */ onMenuChange(menu) { // TODO: router stuff here? - this.setState({history: {page: menu}}); + const history = this.state.history; + history.page = menu; + this.setState({history: history}); + } + + /** + * Received router change. + * + * @param event + */ + onPathChange(event) { + this.setState( + { + pathname: event.pathname, + history: event.state + } + ); } /** @@ -82,6 +114,56 @@ class Application extends Component { this.registeredButtonHandler.splice(index, 1); } + /** + * Decide the page + * @param pathname + */ + restorePageByPathName(pathname) { + const pages = Application.routes; + + // fallback + let history = { + page: 'index', + root: '' + }; + + let found = false; + // create history by pathname detection. + for(let page in pages) { + let index = pathname.indexOf(pages[page]); + /** + * Check if in path and goes to end. + * TODO: When data attached to path, is it needed to change this logic. + */ + if(index !== -1 && pages[page].length + index === pathname.length) { + history = { + page: page + }; + + break; + } + } + + // actualize root pathname + history.root = pathname.substr(0, pathname.lastIndexOf(pages[history.page])); + + return history; + } + + /** + * Decider routing + * + * @param {Object} nextProps + * @param {Object} nextState + */ + componentWillUpdate(nextProps, nextState) { + if (nextState.history === null) { + nextState.history = this.restorePageByPathName(nextState.pathname); + } + // Go to route + nextState.pathname = nextState.history.root + Application.routes[nextState.history.page]; + } + /** * Adding singleton components to application. * @@ -90,7 +172,11 @@ class Application extends Component { render() { return (
- + {super.render()}
); diff --git a/src/Application/Application.test.js b/src/Application/Application.test.js index d004171..8dfb828 100644 --- a/src/Application/Application.test.js +++ b/src/Application/Application.test.js @@ -1,27 +1,28 @@ +/* global jest, Babel */ + import {mockAxiosAction} from 'axios'; import React from 'react'; import {shallow} from 'enzyme'; import Application from './Application'; jest.mock('../Menu', () => 'Menu'); +jest.mock('../Shared/Router', () => 'Router'); /** * Test Application Container. */ describe('Application', function testApplication() { + let bound = null; + let success = false; + let promise; + /** * Reset global mocks. */ beforeEach(function beforeEach() { Babel.transform.mockClear(); - }); - /** - * Test if correct layout loaded. - */ - it('Loads the correct layout', function testLoadLayout() { - let bound = null; - const promise = { + promise = { then: function onThen(callback) { bound = callback; return promise; @@ -31,8 +32,6 @@ describe('Application', function testApplication() { } }; - let success = false; - mockAxiosAction( 'get', function onRequest(url) { @@ -47,7 +46,25 @@ describe('Application', function testApplication() { code: 'React.createElement(\'button\', { onClick: this.onMainButtonClick.bind(this) })' } ); + }); + + /** + * Test if correct layout loaded. + */ + it('Loads the correct layout', function testLoadLayout() { + const wrapper = shallow(); + const instance = wrapper.instance(); + bound({data: 'TEMPLATE'}); + wrapper.update(); + // Template loaded? + expect(success).toBe(true); + }); + + /** + * Test if menu interaction works. + */ + it('Test menu interaction', function testLoadLayout() { const wrapper = shallow(); const instance = wrapper.instance(); const buttonClick = jest.fn(); @@ -58,10 +75,63 @@ describe('Application', function testApplication() { wrapper.find('button').simulate('click'); expect(buttonClick).toHaveBeenCalled(); - expect(success).toBe(true); instance.onMenuChange('newMenu'); instance.menuAdapter.deregisterMenuToggleHandler(buttonClick); instance.menuAdapter.deregisterMenuToggleHandler(buttonClick); // check that double remove don't break }); + + /** + * Test kinds of redirection. + */ + it('Redirect from router to page', function testRouterRedirect() { + const wrapper = shallow(); + const instance = wrapper.instance(); + bound({data: 'TEMPLATE'}); + wrapper.update(); + + // start on sub page in a sub directory of hosting page + instance.onPathChange( + { + pathname: '/MainPath/settings/', + state: null + } + ); + expect(instance.state.pathname).toBe('/MainPath/settings/'); + expect(instance.state.history.page).toBe('settings'); + expect(instance.state.history.root).toBe('/MainPath'); + + // start on index page in a sub directory of hosting page + instance.onPathChange( + { + pathname: '/MainPath/', + state: null + } + ); + expect(instance.state.pathname).toBe('/MainPath/'); + expect(instance.state.history.page).toBe('index'); + expect(instance.state.history.root).toBe('/MainPath'); + + // start on sub page in a root directory of hosting page + instance.onPathChange( + { + pathname: '/', + state: null + } + ); + expect(instance.state.pathname).toBe('/'); + expect(instance.state.history.page).toBe('index'); + expect(instance.state.history.root).toBe(''); + + // reload page with using state data + instance.onPathChange( + { + pathname: '/settings/', // <-- should be ignored if state present + state: {page:"index", root:"/MainPath"} + } + ); + expect(instance.state.pathname).toBe('/MainPath/'); + expect(instance.state.history.page).toBe('index'); + expect(instance.state.history.root).toBe('/MainPath'); + }); }); diff --git a/src/Application/Router/Router.js b/src/Application/Router/Router.js deleted file mode 100644 index 04a782c..0000000 --- a/src/Application/Router/Router.js +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -/** - * The History API connection. - */ -class Router extends React.Component -{ - render() { - return null; - } -} - -export default Router; diff --git a/src/Application/Router/index.js b/src/Application/Router/index.js deleted file mode 100644 index 9ae8490..0000000 --- a/src/Application/Router/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import Router from './Router'; - -export default Object.assign( - Router, - {} -); diff --git a/src/Shared/README.md b/src/Shared/README.md index a7ec93f..f2b12a7 100644 --- a/src/Shared/README.md +++ b/src/Shared/README.md @@ -3,3 +3,69 @@ May later will moved some part of the shared domain to extra library projects. + +## Router +The router is and adapter between [History API] and [React]. + +It hat 3 properties, which are needed to work: +* `onChange` - Function +* `pathname` - String +* `state` - Object + +The handler function in `onChange` will be called, when the [History API] +massages a change. That is e.g. happened when the user clicks arrows of the +browser history. + +Over the `pathname` controls the application, which page is in address line +presented. A change of `pathname` leads into a new entry on the browser +history. When the `pathname` is changed, will the `state` also given to the +history API. That state will received on the `onChange` call. +*Notice:* `pathname` and `state` should always changed together. + +### Example +```typescript jsx +class MyComponent extends React.Coomponent { + constructor(props, context, updater) { + super(props, context, updater); + this.state = { + route: '/', + routeState: {} + } + } + + componentWillMount() { + setTimeout( + () => { + this.setState( + { + route: '/test/', + routeState: {foo: 'bar'} + } + ); + }, + 10000 + ); + } + + onRouterChange(event) { + console.log("Route change to", event.pathname, "with state", event.state); + } + + render() + { + return ; + } +} +``` + +## + + + + +[History API]: https://developer.mozilla.org/en-US/docs/Web/API/History_API +[React]: https://reactjs.org/ diff --git a/src/Shared/Router.js b/src/Shared/Router.js new file mode 100644 index 0000000..c5ebe18 --- /dev/null +++ b/src/Shared/Router.js @@ -0,0 +1,99 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +/** + * The History API connection. + * That router is more an adapter between React and the HTML5 History API. + * What to do, if a route is change isn't responsibility of this router. + */ +class Router extends React.Component +{ + /** + * Define properties. + * + * @returns {Object} + */ + static get propTypes() { + return { + onChange: PropTypes.func.isRequired, + state: PropTypes.object.isRequired, + pathname: PropTypes.string.isRequired + }; + } + + /** + * Constructor. + * + * @param {Object} props + * @param {Object} context + * @param {Object} updater + */ + constructor(props, context, updater) { + super(props, context, updater); + + this.history = null; + this.pathname = ""; + + this.boundPopState = this.onPopState.bind(this); + } + + /** + * Connect to browser API. + */ + componentDidMount() { + this.history = global.history; + global.addEventListener('popstate', this.boundPopState); + this.popStateChanged(global.location.pathname, this.history.state); + } + + /** + * Push new state if needed. + * + * @param nextProps + */ + componentWillUpdate(nextProps) { + const pathname = nextProps.pathname; + if (pathname !== this.pathname) { + this.history.pushState(nextProps.state, '', pathname); + this.popStateChanged(pathname, nextProps.state); + } + } + + /** + * Remove connection to browser API. + */ + componentWillUnmount() { + global.removeEventListener('popstate', this.boundPopState); + this.history = null; + } + + /** + * Browser API handler to proxy change events. + * @param {PopStateEvent} event + */ + onPopState(event) { + this.popStateChanged(global.location.pathname, event.state); + } + + /** + * Propagate new state and update cached data. + * + * @param {string} pathname + * @param {Object} state + */ + popStateChanged(pathname, state) + { + this.pathname = pathname; + this.props.onChange({pathname: pathname, state: !state ? null : state}); + } + + /** + * Component does not have visual output. + * @returns {null} + */ + render() { + return null; + } +} + +export default Router; diff --git a/src/Shared/Router.test.js b/src/Shared/Router.test.js new file mode 100644 index 0000000..ff20fef --- /dev/null +++ b/src/Shared/Router.test.js @@ -0,0 +1,55 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import Router from './Router'; + +/** + * Test the router connection. + * The router is more and adapter to global(window). So the test are not very precise and we can mostly only cover + * the lines. If event correct bound, I don't see yet the way to validate it. + * + * Here is an window and History API-Polyfill active. So I can't overwrite the globals with own mocks. + */ +describe('Shared: Router', function testRouter() { + let wrapper, lastEvent; + + /** + * Handler that copied event to `lastEvent`. + * + * @param event + */ + function onChange(event) { + lastEvent = event; + } + + /** + * Create and mount router. + */ + beforeEach(() => { + lastEvent = {}; + wrapper = shallow(); + wrapper.setProps({}); + }); + + /** + * Test start of router. + */ + it('Test change submit on start', function testSubmitOnStart() { + expect(lastEvent.pathname).toBe('/'); + expect(lastEvent.state).toEqual(null); + wrapper.unmount(); + }); + + /** + * Test change submit on route changes. + */ + it('Test change page', function testSubmitOnChange() { + const instance = wrapper.instance(); + wrapper.setProps({state: {foo: 'bar'}, pathname: '/newPage/'}); + expect(lastEvent.pathname).toBe('/newPage/'); + expect(lastEvent.state).toEqual({foo: 'bar'}); + + // test (indirect) event firing + instance.boundPopState({state: {foo: 'bar2'}}); + expect(lastEvent.state).toEqual({foo: 'bar2'}); + }); +});