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'});
+ });
+});