diff --git a/README.md b/README.md index 248e98d0..531c253f 100644 --- a/README.md +++ b/README.md @@ -42,24 +42,25 @@ Find more examples [here](https://github.com/Travix-International/frint/tree/mas The framework is a collection of these packages, which can be composed together on demand: -| Package | Status | Description | -|-----------------------------|------------------------------------------------------------------------|-------------| -| [frint] | [![frint-status]][frint-package] | Base for creating Apps | -| [frint-store] | [![frint-store-status]][frint-store-package] | State management with reactive stores | -| [frint-data] | [![frint-data-status]][frint-data-package] | Reactive data modelling | -| [frint-react] | [![frint-react-status]][frint-react-package] | React.js integration | -| [frint-react-server] | [![frint-react-server-status]][frint-react-server-package] | Server-side rendering of Apps | -| [frint-router] | [![frint-router-status]][frint-router-package] | Router services for building Single Page Applications | -| [frint-router-react] | [![frint-router-react-status]][frint-router-react-package] | React components for building SPAs | -| [frint-cli] | [![frint-cli-status]][frint-cli-package] | CLI runner | -| [frint-compat] | [![frint-compat-status]][frint-compat-package] | Backwards compatibility for older versions | -| [frint-model] | [![frint-model-status]][frint-model-package] | Use `frint-data` instead | -| **For library developers:** | | | -| [frint-component-utils] | [![frint-component-utils-status]][frint-component-utils-package] | Utils for reactive Components | -| [frint-component-handlers] | [![frint-component-handlers-status]][frint-component-handlers-package] | Handlers for integrating with other rendering libraries | -| **Internally used:** | | | -| [frint-test-utils] | [![frint-test-utils-status]][frint-test-utils-package] | Internally used test utilities | -| [frint-config] | [![frint-config-status]][frint-config-package] | Common config for your Apps | +| Package | Status | Description | +|------------------------------------|--------------------------------------------------------------------------------------|-------------| +| [frint] | [![frint-status]][frint-package] | Base for creating Apps | +| [frint-store] | [![frint-store-status]][frint-store-package] | State management with reactive stores | +| [frint-data] | [![frint-data-status]][frint-data-package] | Reactive data modelling | +| [frint-react] | [![frint-react-status]][frint-react-package] | React.js integration | +| [frint-react-server] | [![frint-react-server-status]][frint-react-server-package] | Server-side rendering of Apps | +| [frint-router] | [![frint-router-status]][frint-router-package] | Router services for building Single Page Applications | +| [frint-router-react] | [![frint-router-react-status]][frint-router-react-package] | React components for building SPAs | +| [frint-cli] | [![frint-cli-status]][frint-cli-package] | CLI runner | +| [frint-compat] | [![frint-compat-status]][frint-compat-package] | Backwards compatibility for older versions | +| [frint-model] | [![frint-model-status]][frint-model-package] | Use `frint-data` instead | +| **For library developers:** | | | +| [frint-component-utils] | [![frint-component-utils-status]][frint-component-utils-package] | Utils for reactive Components | +| [frint-component-handlers] | [![frint-component-handlers-status]][frint-component-handlers-package] | Handlers for integrating with other rendering libraries | +| [frint-router-component-handlers] | [![frint-router-component-handlers-status]][frint-router-component-handlers-package] | Handlers for integrating `frint-router` with other rendering libraries | +| **Internally used:** | | | +| [frint-test-utils] | [![frint-test-utils-status]][frint-test-utils-package] | Internally used test utilities | +| [frint-config] | [![frint-config-status]][frint-config-package] | Common config for your Apps | [frint]: https://frint.js.org/docs/packages/frint [frint-store]: https://frint.js.org/docs/packages/frint-store @@ -73,6 +74,7 @@ The framework is a collection of these packages, which can be composed together [frint-compat]: https://frint.js.org/docs/packages/frint-compat [frint-component-utils]: https://frint.js.org/docs/packages/frint-component-utils [frint-component-handlers]: https://frint.js.org/docs/packages/frint-component-handlers +[frint-router-component-handlers]: https://frint.js.org/docs/packages/frint-router-component-handlers [frint-test-utils]: https://frint.js.org/docs/packages/frint-test-utils [frint-config]: https://frint.js.org/docs/packages/frint-config @@ -88,6 +90,7 @@ The framework is a collection of these packages, which can be composed together [frint-compat-status]: https://img.shields.io/npm/v/frint-compat.svg [frint-component-utils-status]: https://img.shields.io/npm/v/frint-component-utils.svg [frint-component-handlers-status]: https://img.shields.io/npm/v/frint-component-handlers.svg +[frint-router-component-handlers-status]: https://img.shields.io/npm/v/frint-router-component-handlers.svg [frint-test-utils-status]: https://img.shields.io/npm/v/frint-test-utils.svg [frint-config-status]: https://img.shields.io/npm/v/frint-config.svg @@ -103,6 +106,7 @@ The framework is a collection of these packages, which can be composed together [frint-compat-package]: https://npmjs.com/package/frint-compat [frint-component-utils-package]: https://npmjs.com/package/frint-component-utils [frint-component-handlers-package]: https://npmjs.com/package/frint-component-handlers +[frint-router-component-handlers-package]: https://npmjs.com/package/frint-router-component-handlers [frint-test-utils-package]: https://npmjs.com/package/frint-test-utils [frint-config-package]: https://npmjs.com/package/frint-config diff --git a/packages/frint-router-component-handlers/.gitignore b/packages/frint-router-component-handlers/.gitignore new file mode 100644 index 00000000..5f985010 --- /dev/null +++ b/packages/frint-router-component-handlers/.gitignore @@ -0,0 +1 @@ +dist/*.js diff --git a/packages/frint-router-component-handlers/.npmignore b/packages/frint-router-component-handlers/.npmignore new file mode 100644 index 00000000..a2cfb9e4 --- /dev/null +++ b/packages/frint-router-component-handlers/.npmignore @@ -0,0 +1,5 @@ +src +test +coverage +node_modules +.nyc_output diff --git a/packages/frint-router-component-handlers/README.md b/packages/frint-router-component-handlers/README.md new file mode 100644 index 00000000..122692cb --- /dev/null +++ b/packages/frint-router-component-handlers/README.md @@ -0,0 +1,73 @@ +# frint-router-component-handlers + +[![npm](https://img.shields.io/npm/v/frint-router-component-handlers.svg)](https://www.npmjs.com/package/frint-router-component-handlers) + +> Router component handlers package of Frint + + + +- [Guide](#guide) + - [Installation](#installation) +- [API](#api) + - [createLinkHandler](#createlinkhandler) + - [createRouteHandler](#createroutehandler) + - [createSwitchHandler](#createswitchhandler) + + + +--- + +# Guide + +This package is aimed at enabling other reactive rendering/templating libraries integrate with FrintJS router, and not to be used directly by developers in their applications. + +For example, take a look at `frint-router-react` for its implementation using this package internally. + +## Installation + +With [npm](https://www.npmjs.com/): + +``` +$ npm install --save frint-router-component-handlers +``` + +# API + + +## createLinkHandler + +> createLinkHandler(ComponentHandler, app, component) + +Method for creating handler for `Link` component. + +#### Arguments + +1. `ComponentHandler` (`Object`): framework specific component handler implementing common API to work with `component` +2. `app` (`Object`): app instance +3. `component` (`Object`): `Link` component instance + + +## createRouteHandler + +> createRouteHandler(ComponentHandler, app, component) + +Method for creating handler for `Route` component. + +#### Arguments + +1. `ComponentHandler` (`Object`): framework specific component handler implementing common API to work with `component` +2. `app` (`Object`): app instance +3. `component` (`Object`): `Route` component instance + + +## createSwitchHandler + +> createSwitchHandler(ComponentHandler, app, component) + +Method for creating handler for `Switch` component. + +#### Arguments + +1. `ComponentHandler` (`Object`): framework specific component handler implementing common API to work with `component` +2. `app` (`Object`): app instance +3. `component` (`Object`): `Switch` component instance diff --git a/packages/frint-router-component-handlers/package.json b/packages/frint-router-component-handlers/package.json new file mode 100644 index 00000000..9b289db8 --- /dev/null +++ b/packages/frint-router-component-handlers/package.json @@ -0,0 +1,42 @@ +{ + "name": "frint-router-component-handlers", + "version": "3.0.1", + "description": "Router component handlers package for Frint", + "main": "lib/index.js", + "homepage": "https://github.com/Travix-International/frint/tree/master/packages/frint-router-component-handlers", + "scripts": { + "lint": "cross-env ../../node_modules/.bin/eslint --color '{src,test}/**/*.js'", + "transpile": "cross-env ../../node_modules/.bin/babel src --out-dir lib", + "test": "cross-env ../../node_modules/.bin/mocha --colors --compilers js:babel-register --recursive ./src/**/*.spec.js", + "cover:run": "cross-env ../../node_modules/.bin/nyc --reporter=json --require babel-register ../../node_modules/.bin/mocha --colors --compilers js:babel-register --recursive ./src/**/*.spec.js", + "cover:report": "cross-env ../../node_modules/.bin/nyc report", + "cover": "npm run cover:run && npm run cover:report", + "dist:lib": "cross-env ../../node_modules/.bin/webpack --config ./webpack.config.js", + "dist:min": "cross-env DIST_MIN=1 ../../node_modules/.bin/webpack --config ./webpack.config.js", + "dist": "npm run dist:lib && npm run dist:min", + "prepublish": "npm run transpile" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Travix-International/frint.git" + }, + "author": { + "name": "Travix International B.V.", + "url": "https://travix.com" + }, + "keywords": [ + "frint" + ], + "dependencies": { + "frint-component-utils": "^3.0.1" + }, + "devDependencies": { + "cross-env": "^5.0.5", + "frint-router": "^3.0.1", + "frint": "^3.0.1" + }, + "bugs": { + "url": "https://github.com/Travix-International/frint/issues" + }, + "license": "MIT" +} diff --git a/packages/frint-router-component-handlers/src/createLinkHandler.js b/packages/frint-router-component-handlers/src/createLinkHandler.js new file mode 100644 index 00000000..ed3d687a --- /dev/null +++ b/packages/frint-router-component-handlers/src/createLinkHandler.js @@ -0,0 +1,74 @@ +import { composeHandlers } from 'frint-component-utils'; + +const LinkHandler = { + _routerSubscription: null, + + getInitialData() { + return { + active: false, + }; + }, + + beforeMount() { + this._resubscribeToRouter(this.getProps()); + }, + + propsChange(nextProps, toChanged, exactChanged) { + this._resubscribeToRouter(nextProps, toChanged, exactChanged); + }, + + beforeDestroy() { + this._unsubscribeFromRouter(); + }, + + handleClick() { + const router = this._getRouter(); + const to = this.getProp('to'); + + if (router.getMatch(to, router.getHistory(), { exact: true }) === null) { + router.push(to); + } + }, + + _getRouter() { + return this.app.get('router'); + }, + + _resubscribeToRouter(nextProps, toChanged = false, exactChanged = false) { + const { activeClassName, to, exact } = nextProps; + + this._unsubscribeFromRouter(); + + if (typeof activeClassName === 'string') { + if (!this._routerSubscription || toChanged || exactChanged) { + this._routerSubscription = this._getRouter() + .getMatch$(to, { exact }) + .subscribe((matched) => { + if (!matched) { + return this.setData('active', false); + } + + return this.setData('active', true); + }); + } + } + }, + + _unsubscribeFromRouter() { + if (this._routerSubscription) { + this._routerSubscription.unsubscribe(); + this._routerSubscription = null; + } + }, +}; + +export default function createLinkHandler(ComponentHandler, app, component) { + return composeHandlers( + LinkHandler, + ComponentHandler, + { + app, + component + }, + ); +} diff --git a/packages/frint-router-component-handlers/src/createLinkHandler.spec.js b/packages/frint-router-component-handlers/src/createLinkHandler.spec.js new file mode 100644 index 00000000..481e1a38 --- /dev/null +++ b/packages/frint-router-component-handlers/src/createLinkHandler.spec.js @@ -0,0 +1,74 @@ +/* eslint-disable import/no-extraneous-dependencies, func-names, no-unused-expressions */ +/* global describe, it */ +import { expect } from 'chai'; + +import { MemoryRouterService } from 'frint-router'; +import createLinkHandler from './createLinkHandler'; +import { ComponentHandler, createTestAppInstance, createComponent } from './testHelpers'; + +describe('frint-router-component-handlers › createLinkHandler', function () { + it('creates a handler with getInitialData, beforeMount, propsChange, handleClick, beforeDestroy methods', function () { + const handler = createLinkHandler( + ComponentHandler, + createTestAppInstance(new MemoryRouterService()), + createComponent() + ); + + expect(handler).to.include.all.keys('getInitialData', 'beforeMount', 'propsChange', 'handleClick', 'beforeDestroy'); + }); + + describe('LinkHandler functions properly throughout lifecycle', function () { + const router = new MemoryRouterService(); + + const component = createComponent(); + component.props.to = '/about'; + component.props.exact = false; + component.props.activeClassName = 'active-link'; + + const handler = createLinkHandler( + ComponentHandler, + createTestAppInstance(router), + component + ); + + it('when getInitialData() called, it returns object with active key', function () { + component.data = handler.getInitialData(); + expect(component.data).to.have.all.keys({ active: false }); + }); + + it('when beforeMount() called, subscribes to router and changes active data accordingly', function () { + handler.beforeMount(); + expect(component.data.active).to.be.false; + + router.push('/about'); + + expect(component.data.active).to.be.true; + }); + + it('when props change and propsChange() called, it resubscribes to router and changes active data accordingly', function () { + component.props.to = '/contact'; + handler.propsChange(component.props, true, false); + + expect(component.data.active).to.be.false; + + router.push('/contact/us'); + expect(component.data.active).to.be.true; + }); + + it('when handleClick() is called, it changes router url', function () { + expect(router.getLocation().pathname).to.equal('/contact/us'); + + handler.handleClick(); + + expect(router.getLocation().pathname).to.equal('/contact'); + }); + + it('when beforeDestroy() is called, unsubscribes from router and doesn\'t change active data anymore', function () { + expect(component.data.active).to.be.true; + + handler.beforeDestroy(); + router.push('/random'); + expect(component.data.active).to.be.true; + }); + }); +}); diff --git a/packages/frint-router-component-handlers/src/createRouteHandler.js b/packages/frint-router-component-handlers/src/createRouteHandler.js new file mode 100644 index 00000000..0b190a73 --- /dev/null +++ b/packages/frint-router-component-handlers/src/createRouteHandler.js @@ -0,0 +1,102 @@ +import { composeHandlers } from 'frint-component-utils'; + +const RouteHandler = { + _routerSubscription: null, + _appInstance: null, + + getInitialData() { + return { + component: null, + matched: null, + }; + }, + + beforeMount() { + const props = this.getProps(); + this._calculateMatchedState(props); + this._calculateComponentState(props); + }, + + propsChange(nextProps, pathChanged, exactChanged, appChanged) { + this._calculateMatchedState(nextProps, pathChanged, exactChanged); + this._calculateComponentState(nextProps, appChanged); + }, + + beforeDestroy() { + this._unsubscribeFromRouter(); + this._destroyRouteApp(); + }, + + _getRouter() { + return this.app.get('router'); + }, + + _calculateMatchedState(nextProps, pathChanged = false, exactChanged = false) { + if (nextProps.computedMatch) { + // in case it was subscribed before + this._unsubscribeFromRouter(); + } else if (nextProps.path) { + if (!this._routerSubscription || pathChanged || exactChanged) { + this._unsubscribeFromRouter(); + + this._routerSubscription = this._getRouter() + .getMatch$(nextProps.path, { + exact: nextProps.exact, + }) + .subscribe((matched) => { + this.setData('matched', matched); + }); + } + } + }, + + _calculateComponentState(nextProps, appChanged = false) { + if (nextProps.component) { + // component + this._destroyRouteApp(); + + this.setData('component', nextProps.component); + } else if (nextProps.app && (this._appInstance === null || appChanged)) { + // app + this._destroyRouteApp(); + + const RouteApp = nextProps.app; + + this._appInstance = new RouteApp({ + parentApp: this.app, + }); + + this.setData('component', this.getMountableComponent(this._appInstance)); + } + }, + + _unsubscribeFromRouter() { + if (this._routerSubscription) { + this._routerSubscription.unsubscribe(); + this._routerSubscription = null; + } + }, + + _destroyRouteApp() { + if (this._appInstance) { + this._appInstance.beforeDestroy(); + this._appInstance = null; + } + } +}; + +export default function createRouteHandler(ComponentHandler, app, component) { + if (typeof ComponentHandler.getMountableComponent !== 'function') { + throw new Error('ComponentHandler must provide getMountableComponent() method'); + } + + return composeHandlers( + RouteHandler, + ComponentHandler, + { + app, + component + }, + ); +} + diff --git a/packages/frint-router-component-handlers/src/createRouteHandler.spec.js b/packages/frint-router-component-handlers/src/createRouteHandler.spec.js new file mode 100644 index 00000000..5eb47b86 --- /dev/null +++ b/packages/frint-router-component-handlers/src/createRouteHandler.spec.js @@ -0,0 +1,142 @@ +/* eslint-disable import/no-extraneous-dependencies, func-names, no-unused-expressions */ +/* global describe, it */ +import { expect } from 'chai'; + +import { MemoryRouterService } from 'frint-router'; +import createRouteHandler from './createRouteHandler'; +import { ComponentHandler, createTestAppInstance, createTestApp, createComponent } from './testHelpers'; + +describe('frint-router-component-handlers › createRouteHandler', function () { + it('creates a handler with getInitialData, beforeMount, propsChange, beforeDestroy methods', function () { + const handler = createRouteHandler( + ComponentHandler, + createTestAppInstance(new MemoryRouterService()), + createComponent() + ); + + expect(handler).to.include.all.keys('getInitialData', 'beforeMount', 'propsChange', 'beforeDestroy'); + }); + + it('throws exception when ComponentHandler doesn\'t have getMountableComponent() method', function () { + const invalidCreation = () => { + createRouteHandler( + {}, + createTestAppInstance(new MemoryRouterService()), + createComponent() + ); + }; + + expect(invalidCreation).to.throw(Error, 'ComponentHandler must provide getMountableComponent() method'); + }); + + describe('RouteHandler functions properly throughout lifecycle', function () { + const router = new MemoryRouterService(); + + const component = createComponent(); + const componentToRender = createComponent(); + component.props = { + path: '/products', + exact: true, + component: componentToRender + }; + + const handler = createRouteHandler( + ComponentHandler, + createTestAppInstance(router), + component + ); + + it('initially component and matched data are set to null', function () { + component.data = handler.getInitialData(); + + expect(component.data.component).to.be.null; + expect(component.data.matched).to.be.null; + }); + + it('when beforeMount() is called it subscribes to router and changes matched and component accordingly', function () { + handler.beforeMount(); + + expect(component.data.matched).to.be.null; + expect(component.data.component).to.equal(componentToRender); + + router.push('/products'); + + expect(component.data.matched).to.be.an('object'); + }); + + it('when component and app props changed and propsChange() is called, component data is set to app\'s component', function () { + const appComponent = createComponent(); + component.props.component = undefined; + + component.props.app = createTestApp(undefined, appComponent); + + handler.propsChange(component.props, false, false, true); + + expect(component.data.component).to.equal(appComponent); + }); + + it('when path prop change and propsChange() is called, matched data is changed', function () { + expect(component.data.matched).to.be.an('object'); + + component.props.path = '/contact'; + handler.propsChange(component.props, true, false, false); + + expect(component.data.matched).to.be.null; + }); + + it('when beforeDestroy() is called, it unsubscribes from router and matched no longer changes', function () { + expect(component.data.matched).to.be.null; + + handler.beforeDestroy(); + router.push('/contact'); + + expect(component.data.matched).to.be.null; + }); + }); + + describe('RouteHandler functions properly with computedMatch throughout lifecycle', function () { + const router = new MemoryRouterService(); + + const component = createComponent(); + const componentToRender = createComponent(); + const computedMatch = {}; + + component.props = { + path: '/products', + exact: true, + computedMatch, + component: componentToRender + }; + + const handler = createRouteHandler( + ComponentHandler, + createTestAppInstance(router), + component + ); + + router.push('/'); + + it('initially matched data is set to null', function () { + component.data = handler.getInitialData(); + + expect(component.data.matched).to.be.null; + }); + + it('when beforeMount() is called it doesn\'t subscribe to router and therefore matched data is not affected', function () { + expect(component.data.matched).to.be.null; + + handler.beforeMount(); + + router.push('/products'); + + expect(component.data.matched).to.be.null; + }); + + it('when computedMatch prop is removed and propChanged() is called it subscribes to router and therefore matched data is affected', function () { + component.props.computedMatch = null; + handler.propsChange(component.props, false, false, false); + + expect(component.data.matched).to.be.an('object'); + }); + }); +}); diff --git a/packages/frint-router-component-handlers/src/createSwitchHandler.js b/packages/frint-router-component-handlers/src/createSwitchHandler.js new file mode 100644 index 00000000..24268415 --- /dev/null +++ b/packages/frint-router-component-handlers/src/createSwitchHandler.js @@ -0,0 +1,54 @@ +import { composeHandlers } from 'frint-component-utils'; + +const SwitchHandler = { + _routerSubscription: null, + + getInitialData() { + return { + history: null + }; + }, + + beforeMount() { + this._subscribeToRouter(); + }, + + beforeDestroy() { + this._unsubscribeFromRouter(); + }, + + getMatch(path, exact) { + return this._getRouter() + .getMatch(path, this.getData('history'), { exact }); + }, + + _getRouter() { + return this.app.get('router'); + }, + + _subscribeToRouter() { + this._routerSubscription = this._getRouter() + .getHistory$() + .subscribe((history) => { + this.setData('history', history); + }); + }, + + _unsubscribeFromRouter() { + if (this._routerSubscription) { + this._routerSubscription.unsubscribe(); + this._routerSubscription = null; + } + }, +}; + +export default function createSwitchHandler(ComponentHandler, app, component) { + return composeHandlers( + SwitchHandler, + ComponentHandler, + { + app, + component + }, + ); +} diff --git a/packages/frint-router-component-handlers/src/createSwitchHandler.spec.js b/packages/frint-router-component-handlers/src/createSwitchHandler.spec.js new file mode 100644 index 00000000..e1060150 --- /dev/null +++ b/packages/frint-router-component-handlers/src/createSwitchHandler.spec.js @@ -0,0 +1,81 @@ +/* eslint-disable import/no-extraneous-dependencies, func-names, no-unused-expressions */ +/* global describe, it */ +import { expect } from 'chai'; + +import { MemoryRouterService } from 'frint-router'; +import createSwitchHandler from './createSwitchHandler'; +import { ComponentHandler, createTestAppInstance, createComponent } from './testHelpers'; + +describe('frint-router-component-handlers › createSwitchHandler', function () { + it('creates a handler with getInitialData, beforeMount, beforeDestroy, getMatch methods', function () { + const handler = createSwitchHandler( + ComponentHandler, + createTestAppInstance(new MemoryRouterService()), + createComponent() + ); + + expect(handler).to.include.all.keys('getInitialData', 'beforeMount', 'beforeDestroy', 'getMatch'); + }); + + it('when getInitialData is called, it returns object with history key', function () { + const handler = createSwitchHandler( + ComponentHandler, + createTestAppInstance(new MemoryRouterService()), + createComponent() + ); + + const data = handler.getInitialData(); + expect(data).to.be.an('object'); + expect(data).to.have.all.keys('history'); + }); + + describe('SwitchHandler functions properly throughout lifecycle', function () { + const router = new MemoryRouterService(); + const component = createComponent(); + + const handler = createSwitchHandler( + ComponentHandler, + createTestAppInstance(router), + component + ); + + component.data = handler.getInitialData(); + + it('initially history is set to null', function () { + expect(component.data.history).to.be.null; + }); + + it('when beforeMount is called, it subscribes to the router and changes history data whenever it changes in router', function () { + handler.beforeMount(); + + const history1 = component.data.history; + expect(history1).to.not.be.null; + + router.push('/contact'); + + const history2 = component.data.history; + expect(history2).to.not.be.null; + expect(history2).to.not.equal(history1); + }); + + it('when getMatch is called, it matches history correctly from the router', function () { + router.push('/about'); + + expect(handler.getMatch('/', true)).to.be.null; + expect(handler.getMatch('/', false)).to.be.an('object'); + expect(handler.getMatch('/about', true)).to.be.an('object'); + expect(handler.getMatch('/about', false)).to.be.an('object'); + }); + + it('when beforeDestroy() is called, it unsubscribes from router and history data no longer changes', function () { + handler.beforeDestroy(); + + const history1 = component.data.history; + + router.push('/destroyed'); + + const history2 = component.data.history; + expect(history2).to.equal(history1); + }); + }); +}); diff --git a/packages/frint-router-component-handlers/src/index.js b/packages/frint-router-component-handlers/src/index.js new file mode 100644 index 00000000..112d00cb --- /dev/null +++ b/packages/frint-router-component-handlers/src/index.js @@ -0,0 +1,9 @@ +import createLinkHandler from './createLinkHandler'; +import createRouteHandler from './createRouteHandler'; +import createSwitchHandler from './createSwitchHandler'; + +export default { + createLinkHandler, + createRouteHandler, + createSwitchHandler +}; diff --git a/packages/frint-router-component-handlers/src/index.spec.js b/packages/frint-router-component-handlers/src/index.spec.js new file mode 100644 index 00000000..c8430c0d --- /dev/null +++ b/packages/frint-router-component-handlers/src/index.spec.js @@ -0,0 +1,12 @@ +/* eslint-disable import/no-extraneous-dependencies, func-names */ +/* global describe, it */ +import { expect } from 'chai'; + +import index from './index'; + +describe('frint-router-component-handlers › index', function () { + it('exports an object with createLinkHandler, createRouteHandler, createSwitchHandler keys', function () { + expect(index).to.be.an('object'); + expect(index).to.have.all.keys('createLinkHandler', 'createRouteHandler', 'createSwitchHandler'); + }); +}); diff --git a/packages/frint-router-component-handlers/src/testHelpers.js b/packages/frint-router-component-handlers/src/testHelpers.js new file mode 100644 index 00000000..9323149c --- /dev/null +++ b/packages/frint-router-component-handlers/src/testHelpers.js @@ -0,0 +1,48 @@ +import { createApp } from 'frint'; // eslint-disable-line import/no-extraneous-dependencies + +export const ComponentHandler = { + setData(key, value) { + this.component.data[key] = value; + }, + getData(key) { + return this.component.data[key]; + }, + getProp(key) { + return this.component.props[key]; + }, + getProps() { + return this.component.props; + }, + getMountableComponent(app) { + return app.get('component'); + } +}; + +export function createTestApp(router, component) { + const providers = []; + + if (router) { + providers.push({ name: 'router', useValue: router }); + } + + if (component) { + providers.push({ name: 'component', useValue: component }); + } + + return createApp({ + name: 'TestApp', + providers + }); +} + +export function createTestAppInstance(router, component) { + const AppClass = createTestApp(router, component); + return new AppClass(); +} + +export function createComponent() { + return { + data: {}, + props: {} + }; +} diff --git a/packages/frint-router-component-handlers/webpack.config.js b/packages/frint-router-component-handlers/webpack.config.js new file mode 100644 index 00000000..ab5caccc --- /dev/null +++ b/packages/frint-router-component-handlers/webpack.config.js @@ -0,0 +1,46 @@ +var webpack = require('webpack'); + +var minify = process.env.DIST_MIN; +var plugins = !minify + ? [] + : [ + new webpack.optimize.UglifyJsPlugin({ + compress: { + warnings: false, + drop_console: false + } + }) + ]; +var filename = !minify + ? 'frint-router-component-handlers.js' + : 'frint-router-component-handlers.min.js'; + +module.exports = { + entry: __dirname + '/src', + output: { + path: __dirname + '/dist', + filename: filename, + libraryTarget: 'this', + library: 'FrintRouterComponentHandlers' + }, + externals: { + 'lodash': '_', + 'rxjs': 'Rx', + }, + target: 'web', + plugins: plugins, + module: { + rules: [ + { + test: /\.js$/, + exclude: /(node_modules)/, + loader: 'babel-loader', + query: { + presets: [ + 'travix' + ] + } + } + ] + } +}; diff --git a/packages/frint-router-react/package.json b/packages/frint-router-react/package.json index f6c135f9..8dda44d6 100644 --- a/packages/frint-router-react/package.json +++ b/packages/frint-router-react/package.json @@ -28,13 +28,14 @@ "frint" ], "dependencies": { - "frint-router": "^3.0.1" + "frint-react": "^3.0.1", + "frint-router": "^3.0.1", + "frint-router-component-handlers": "^3.0.1" }, "devDependencies": { "cross-env": "^5.0.5", "frint": "^3.0.1", "frint-config": "^3.0.1", - "frint-react": "^3.0.1", "frint-test-utils": "^3.0.1" }, "bugs": { diff --git a/packages/frint-router-react/src/Link.js b/packages/frint-router-react/src/Link.js index f6eec37b..9a64b5ef 100644 --- a/packages/frint-router-react/src/Link.js +++ b/packages/frint-router-react/src/Link.js @@ -1,5 +1,7 @@ import React from 'react'; // eslint-disable-line import/no-extraneous-dependencies import PropTypes from 'prop-types'; // eslint-disable-line import/no-extraneous-dependencies +import { createLinkHandler } from 'frint-router-component-handlers'; +import { ReactHandler } from 'frint-react'; export default class Link extends React.Component { static contextTypes = { @@ -18,65 +20,30 @@ export default class Link extends React.Component { constructor(...args) { super(...args); - this.state = { - active: false, - }; + this._handler = createLinkHandler(ReactHandler, this.context.app, this); - this.subscription = null; + this.state = this._handler.getInitialData(); } componentDidMount() { - this.considerSubscribingToRouter(this.props); + this._handler.beforeMount(); } componentWillReceiveProps(nextProps) { - this.considerSubscribingToRouter(nextProps); - } - - componentWillUnmount() { - this.unsubscribeFromRouter(); - } - - considerSubscribingToRouter(nextProps) { - if (typeof nextProps.activeClassName === 'string') { - if (!this.subscription || - this.props.to !== nextProps.to || - this.props.exact !== nextProps.exact) { - this.resubscribeToRouter(nextProps.to, nextProps.exact); - } - } - } - - resubscribeToRouter(to, exact) { - this.unsubscribeFromRouter(); + const toChanged = (this.props.to !== nextProps.to); + const exactChanged = (this.props.exact !== nextProps.exact); - this.subscription = this.context.app - .get('router') - .getMatch$(to, { exact }) - .subscribe((matched) => { - if (!matched) { - return this.setState({ active: false }); - } - - return this.setState({ active: true }); - }); + this._handler.propsChange(nextProps, toChanged, exactChanged); } - unsubscribeFromRouter() { - if (this.subscription) { - this.subscription.unsubscribe(); - } + componentWillUnmount() { + this._handler.beforeDestroy(); } handleClick = (e) => { e.preventDefault(); - const router = this.context.app.get('router'); - const to = this.props.to; - - if (router.getMatch(to, router.getHistory(), { exact: true }) === null) { - router.push(to); - } + this._handler.handleClick(); }; render() { @@ -93,7 +60,7 @@ export default class Link extends React.Component { className: className || '', }; - if (this.state.active) { + if (this._handler.getData('active')) { linkProps.className += ` ${activeClassName}`; } diff --git a/packages/frint-router-react/src/Link.spec.js b/packages/frint-router-react/src/Link.spec.js index e36a4863..ec65a66c 100644 --- a/packages/frint-router-react/src/Link.spec.js +++ b/packages/frint-router-react/src/Link.spec.js @@ -118,9 +118,8 @@ describe('frint-route-react › Link', () => { expect(wrapper.hasClass('anchor-active')).to.be.false; }); - it('subscribes to router service for matching route and changes activeClass accordingly ' + - 'even when activeClassName is passed later on', function () { + 'even when activeClassName is passed and changed later on', function () { const router = new MemoryRouterService(); const wrapper = mount( @@ -139,6 +138,9 @@ describe('frint-route-react › Link', () => { wrapper.setProps({ activeClassName: 'anchor-active' }); expect(wrapper.hasClass('anchor-active')).to.be.true; + + wrapper.setProps({ activeClassName: 'anchor-super-active' }); + expect(wrapper.hasClass('anchor-super-active')).to.be.true; }); it('subscribes to router service for matching route and changes activeClass accordingly ' + diff --git a/packages/frint-router-react/src/Route.js b/packages/frint-router-react/src/Route.js index 70063383..ec1300ab 100644 --- a/packages/frint-router-react/src/Route.js +++ b/packages/frint-router-react/src/Route.js @@ -1,8 +1,9 @@ /* eslint-disable import/no-extraneous-dependencies */ import React from 'react'; import PropTypes from 'prop-types'; -import { getMountableComponent } from 'frint-react'; +import { ReactHandler } from 'frint-react'; /* eslint-enable import/no-extraneous-dependencies */ +import { createRouteHandler } from 'frint-router-component-handlers'; export default class Route extends React.Component { static contextTypes = { @@ -21,91 +22,30 @@ export default class Route extends React.Component { constructor(...args) { super(...args); - this._routerSubscription = null; - this._appInstance = null; + this._handler = createRouteHandler(ReactHandler, this.context.app, this); - this.state = { - component: null, - matched: null, - }; + this.state = this._handler.getInitialData(); } componentWillMount() { - this._calculateMatchedState(this.props); - this._calculateComponentState(this.props); + this._handler.beforeMount(); } componentWillReceiveProps(nextProps) { - this._calculateMatchedState(nextProps); - this._calculateComponentState(nextProps); - } - - _calculateMatchedState(nextProps) { - if (nextProps.computedMatch) { - // in case it was subscribed before - this._unsubscribeFromRouter(); - } else if (nextProps.path) { - if (!this._routerSubscription || (nextProps.path !== this.props.path) || (nextProps.exact !== this.props.exact)) { - this._unsubscribeFromRouter(); + const pathChanged = (this.props.path !== nextProps.path); + const exactChanged = (this.props.exact !== nextProps.exact); + const appChanged = (this.props.app !== nextProps.app); - this._routerSubscription = this.context.app - .get('router') - .getMatch$(nextProps.path, { - exact: nextProps.exact, - }) - .subscribe((matched) => { - this.setState({ - matched, - }); - }); - } - } - } - - _calculateComponentState(nextProps) { - if (nextProps.component) { - // component - this._destroyRouteApp(); - - this.setState({ - component: nextProps.component, - }); - } else if (nextProps.app && (this._appInstance === null || nextProps.app !== this.props.app)) { - // app - this._destroyRouteApp(); - - const RouteApp = nextProps.app; - - this._appInstance = new RouteApp({ - parentApp: this.context.app, - }); - this.setState({ - component: getMountableComponent(this._appInstance) - }); - } + this._handler.propsChange(nextProps, pathChanged, exactChanged, appChanged); } componentWillUnmount() { - this._unsubscribeFromRouter(); - this._destroyRouteApp(); - } - - _unsubscribeFromRouter() { - if (this._routerSubscription) { - this._routerSubscription.unsubscribe(); - } - } - - _destroyRouteApp() { - if (this._appInstance) { - this._appInstance.beforeDestroy(); - this._appInstance = null; - } + this._handler.beforeDestroy(); } render() { - const ComponentToRender = this.state.component; - const matched = this.props.computedMatch || this.state.matched || null; + const ComponentToRender = this._handler.getData('component'); + const matched = this.props.computedMatch || this._handler.getData('matched') || null; if (!matched) { return null; diff --git a/packages/frint-router-react/src/Route.spec.js b/packages/frint-router-react/src/Route.spec.js index d296f385..208e412b 100644 --- a/packages/frint-router-react/src/Route.spec.js +++ b/packages/frint-router-react/src/Route.spec.js @@ -27,6 +27,10 @@ function createContext() { }; } +function getAppInstance(wrapper) { + return wrapper.instance()._handler._appInstance; +} + describe('frint-route-react › Route', function () { before(function () { resetDOM(); @@ -211,7 +215,7 @@ describe('frint-route-react › Route', function () { ); it('registers app with parent app from context', function () { - const aboutApp = wrapper.instance()._appInstance; + const aboutApp = getAppInstance(wrapper); expect(aboutApp.getParentApp()).to.equal(context.app); }); @@ -285,7 +289,7 @@ describe('frint-route-react › Route', function () { it('instantiates AboutApp and registers it with parent app from context', function () { wrapper.setProps({ app: AboutApp, component: undefined }); - aboutApp = wrapper.instance()._appInstance; + aboutApp = getAppInstance(wrapper); expect(aboutApp.getParentApp()).to.equal(context.app); expect(wrapper.html()).to.equal('
About
'); @@ -294,7 +298,7 @@ describe('frint-route-react › Route', function () { it('doesn\'t destroy the app and doesn\'t reinitialise it when it\'s the same app', function () { wrapper.setProps({ app: AboutApp }); expect(beforeDestroyAboutCallCount).to.equal(0); - expect(wrapper.instance()._appInstance).to.equal(aboutApp); + expect(getAppInstance(wrapper)).to.equal(aboutApp); }); it('calls beforeDestroy for AboutApp when app is changed', function () { @@ -303,7 +307,7 @@ describe('frint-route-react › Route', function () { }); it('instantiates servicesApp and registers it with parent app from context', function () { - const servicesApp = wrapper.instance()._appInstance; + const servicesApp = getAppInstance(wrapper); expect(servicesApp.getParentApp()).to.equal(context.app); expect(servicesApp).to.not.equal(aboutApp); }); @@ -317,7 +321,7 @@ describe('frint-route-react › Route', function () { wrapper.setProps({ app: undefined, component: HomeComponent }); expect(beforeDestroyServicesCallCount).to.equal(1); - expect(wrapper.instance()._appInstance).to.be.null; + expect(getAppInstance(wrapper)).to.be.null; }); it('renders HomeComponent', function () { diff --git a/packages/frint-router-react/src/Switch.js b/packages/frint-router-react/src/Switch.js index 7975b20a..c3d17c0d 100644 --- a/packages/frint-router-react/src/Switch.js +++ b/packages/frint-router-react/src/Switch.js @@ -2,6 +2,8 @@ import React from 'react'; import PropTypes from 'prop-types'; /* eslint-enable import/no-extraneous-dependencies */ +import { createSwitchHandler } from 'frint-router-component-handlers'; +import { ReactHandler } from 'frint-react'; export default class Switch extends React.Component { static contextTypes = { @@ -15,28 +17,17 @@ export default class Switch extends React.Component { constructor(...args) { super(...args); - this.state = { - history: null, - }; + this._handler = createSwitchHandler(ReactHandler, this.context.app, this); - this._routerSubscription = null; + this.state = this._handler.getInitialData(); } componentWillMount() { - this._routerSubscription = this.context.app - .get('router') - .getHistory$() - .subscribe((history) => { - this.setState({ - history, - }); - }); + this._handler.beforeMount(); } componentWillUnmount() { - if (this._routerSubscription) { - this._routerSubscription.unsubscribe(); - } + this._handler.beforeDestroy(); } render() { @@ -54,9 +45,7 @@ export default class Switch extends React.Component { const { path, exact } = element.props; // if Route has no path (it's default) then getMatch will return match with whatever URL - const match = this.context.app - .get('router') - .getMatch(path, this.state.history, { exact }); + const match = this._handler.getMatch(path, { exact }); if (match !== null) { child = React.cloneElement(element, { diff --git a/site/content/docs/packages/frint-router-component-handlers.md b/site/content/docs/packages/frint-router-component-handlers.md new file mode 100644 index 00000000..eed61316 --- /dev/null +++ b/site/content/docs/packages/frint-router-component-handlers.md @@ -0,0 +1,5 @@ +--- +title: frint-router-component-handlers +importContentFromPackage: frint-router-component-handlers +sidebarPartial: docsSidebar +--- diff --git a/site/partials/docsSidebar.html b/site/partials/docsSidebar.html index b3fce4a4..2251d21b 100644 --- a/site/partials/docsSidebar.html +++ b/site/partials/docsSidebar.html @@ -24,6 +24,7 @@
  • frint-config
  • frint-component-utils
  • frint-component-handlers
  • +
  • frint-router-component-handlers
  • frint-test-utils
  • frint-compat