├── .babelrc ├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── LICENSE ├── README.md ├── package-lock.json ├── package.json └── src ├── __tests__ ├── __snapshots__ │ ├── index.js.snap │ └── utils.js.snap ├── index.js ├── renderer.js └── utils.js ├── index.js ├── renderer.js └── utils.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react" 4 | ], 5 | "plugins": [ 6 | "@babel/plugin-transform-arrow-functions", 7 | "@babel/plugin-transform-destructuring", 8 | "@babel/plugin-transform-modules-commonjs", 9 | "@babel/plugin-transform-template-literals" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_size = 4 6 | indent_style = space 7 | insert_final_newline = true 8 | 9 | [{package.json,.travis.yml}] 10 | indent_size = 2 -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: 16 13 | - run: npm install 14 | - run: npm test 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | node_modules 3 | coverage 4 | .babelrc 5 | src 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | message = "chore(version): bump version to %s" 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Truffls GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-in-angular ![Build Status](https://github.com/truffls/react-in-angular/actions/workflows/main.yml/badge.svg) 2 | 3 | ## Installation 4 | 5 | ``` 6 | npm install -S react-in-angular react react-dom 7 | ``` 8 | 9 | _Note: `react` and `react-dom` are peer dependencies and are needed to run `react-in-angular`_ 10 | 11 | ## Usage 12 | 13 | Create your React component: 14 | ```js 15 | const ClickMeButton = (props) => { 16 | return ( 17 | 20 | ) 21 | }; 22 | 23 | export default ClickMeButton; 24 | ``` 25 | 26 | Wrap it with `react-in-angular` and register it as AngularJS component: 27 | ```js 28 | import { toComponent } from 'react-in-angular'; 29 | 30 | import ClickMeButton from './ClickMeButton'; 31 | 32 | // Define the bindings for ClickMeButton 33 | const bindings = { 34 | type: '<', 35 | onClick: '&' 36 | }; 37 | 38 | 39 | angular 40 | .module('app.button') 41 | .component('clickMeButton', toComponent()(ClickMeButton, bindings)); 42 | 43 | ``` 44 | 45 | In your angular controller: 46 | ```js 47 | function Controller () { 48 | this.type = 'button'; 49 | this.onClick = ($event) => { 50 | $event.preventDefault(); 51 | 52 | alert('Clicked!'); 53 | }; 54 | } 55 | ``` 56 | 57 | In your angular template: 58 | ```html 59 | 60 | ``` 61 | 62 | ### Supported bindings 63 | 64 | Because of compatibility with React's one-way data flow only two bindings are supported: 65 | * `<` – for data 66 | * `&` – for functions 67 | 68 | Internally all bindings of type `&` are handled like event handlers. Because of that all events which are passed to the event handlers will be wrapped in a scope where your event is accessible as `$event`. In Angular you can pass `$event` to your event handler: 69 | 70 | ```js 71 | 72 | ``` 73 | 74 | _Note: The property name `$event` was advised in [AngularJS Styleguide by Todd Motto](https://github.com/toddmotto/angularjs-styleguide#one-way-dataflow-and-events) and is described in the [documentation](https://docs.angularjs.org/guide/expression#-event-) of AngularJS._ 75 | 76 | ### Decorators 77 | 78 | With decorators you have the option to provide contexts for your React components or wrap it with logic which is provided by your AngularJS application. 79 | 80 | #### How to write a decorators 81 | 82 | Decorators are simple AngularJS factory functions which return a decorate function. The decorate function takes a render function as argument which will be used to render the actual component. 83 | 84 | An example decorator called `ReduxDecorator` which reuses the Redux store of `$ngRedux`: 85 | ```js 86 | import { Provider } from 'react-redux'; 87 | 88 | function ReduxDecorator ($ngRedux) { 89 | return function decorate (render) { 90 | return ( 91 | 92 | {render()} 93 | 94 | ); 95 | }; 96 | } 97 | 98 | export default ReduxDecorator; 99 | ``` 100 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-in-angular", 3 | "version": "3.1.1", 4 | "description": "Use your React component in Angular", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist/", 8 | "build": "BABEL_ENV=production babel src --ignore \"**/__tests__/**\" --out-dir dist", 9 | "prepublishOnly": "npm run build", 10 | "test": "jest", 11 | "test:watch": "jest --coverage --watch" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/truffls/react-in-angular.git" 16 | }, 17 | "keywords": [ 18 | "angular", 19 | "react", 20 | "component" 21 | ], 22 | "author": "Florian Goße ", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/truffls/react-in-angular/issues" 26 | }, 27 | "homepage": "https://github.com/truffls/react-in-angular#readme", 28 | "devDependencies": { 29 | "@babel/cli": "^7.0.0", 30 | "@babel/core": "^7.0.0", 31 | "@babel/plugin-transform-arrow-functions": "^7.2.0", 32 | "@babel/plugin-transform-destructuring": "^7.5.0", 33 | "@babel/plugin-transform-modules-commonjs": "^7.5.0", 34 | "@babel/plugin-transform-template-literals": "^7.4.4", 35 | "@babel/preset-react": "^7.0.0", 36 | "angular": "^1.6.4", 37 | "angular-mocks": "^1.6.4", 38 | "babel-jest": "^24.8.0", 39 | "jest": "^24.8.0", 40 | "react": "^17.0.2", 41 | "react-dom": "^17.0.2", 42 | "react-test-renderer": "^17.0.2", 43 | "rimraf": "^2.6.1" 44 | }, 45 | "peerDependencies": { 46 | "react": "^16.0.0 || ^17.0.0", 47 | "react-dom": "^16.0.0 || ^17.0.0" 48 | }, 49 | "jest": { 50 | "testEnvironment": "jsdom", 51 | "testPathIgnorePatterns": [ 52 | "/node_modules/", 53 | "/dist/" 54 | ] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/index.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`toComponent createComponent AngularJS component renders correctly with props 1`] = ` 4 | 5 | My 1st Title 6 | 7 | `; 8 | 9 | exports[`toComponent createComponent AngularJS component renders correctly with props 2`] = ` 10 | 11 | My 2nd Title 12 | 13 | `; 14 | 15 | exports[`toComponent createComponent AngularJS component renders correctly without props 1`] = ` 16 | 17 | MyComponent 18 | 19 | `; 20 | 21 | exports[`toComponent createComponent Controller creates correct WrappedComponent 1`] = ` 22 | 25 | 28 | 29 | MyTitle 30 | 31 | 32 | 33 | `; 34 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/utils.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` 1`] = ` 4 | 7 | 10 | 11 | MyContent 12 | 13 | 14 | 15 | `; 16 | -------------------------------------------------------------------------------- /src/__tests__/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import angular from 'angular'; 4 | import 'angular-mocks'; 5 | 6 | import { toComponent } from '../'; 7 | import { createRenderer } from '../renderer'; 8 | 9 | jest.mock('../renderer.js',() => { 10 | const original = require.requireActual('../renderer.js'); 11 | 12 | return Object.assign({}, original, { 13 | createRenderer: jest.fn(original.createRenderer) 14 | }); 15 | }); 16 | 17 | afterEach(() => { 18 | createRenderer.mockClear(); 19 | }); 20 | 21 | afterAll(() => { 22 | jest.unmock('../renderer.js'); 23 | }); 24 | 25 | describe('toComponent', () => { 26 | test('creates a function', () => { 27 | const decorators = []; 28 | 29 | const createComponent = toComponent(decorators); 30 | 31 | expect(typeof createComponent).toBe('function'); 32 | }); 33 | 34 | test('use empty array if no decorators are passed', () => { 35 | const createComponent = toComponent(); 36 | 37 | expect(createComponent._decorators).toHaveLength(0); 38 | }); 39 | 40 | describe('createComponent', () => { 41 | test('returns correct AngularJS component', () => { 42 | const decorators = []; 43 | const Component = (props) => ({props.title}); 44 | const bindings = { title: '<' }; 45 | 46 | const ngComponent = toComponent(decorators)(Component, bindings); 47 | 48 | expect(typeof ngComponent.controller).toBe('function'); 49 | expect(ngComponent.bindings).toEqual(bindings); 50 | }); 51 | 52 | test('use empty object if no bindings are passed', () => { 53 | const decorators = []; 54 | const Component = () => (MyTitle); 55 | 56 | const ngComponent = toComponent(decorators)(Component); 57 | 58 | expect(ngComponent.bindings).toEqual({}); 59 | }); 60 | 61 | describe('Controller', () => { 62 | test('resolves decorators correctly', () => { 63 | const decorators = [ 64 | () => (render) => ({render()}), 65 | () => (render) => ({render()}) 66 | ]; 67 | const Component = (props) => ({props.title}); 68 | const bindings = { title: '<' }; 69 | 70 | const $element = [ document.createElement('div') ]; 71 | const $injectInvoke = jest.fn((factory) => factory()); 72 | const $injector = { invoke: $injectInvoke }; 73 | 74 | const { controller: Controller } = toComponent(decorators)(Component, bindings); 75 | const ctrl = new Controller($element, $injector); 76 | 77 | 78 | expect($injectInvoke.mock.calls).toHaveLength(decorators.length); 79 | 80 | for (let i = 0, l = decorators.length; i < l; i++) { 81 | const call = $injectInvoke.mock.calls[i]; 82 | const decorator = decorators[i]; 83 | 84 | expect(call).toEqual([ decorator ]); 85 | } 86 | }); 87 | 88 | test('creates correct WrappedComponent', () => { 89 | const decorators = [ 90 | () => (render) => ({render()}), 91 | () => (render) => ({render()}) 92 | ]; 93 | const Component = (props) => ({props.title}); 94 | const bindings = { title: '<' }; 95 | 96 | const $element = [ document.createElement('div') ]; 97 | const $injectInvoke = (factory) => factory(); 98 | const $injector = { invoke: $injectInvoke }; 99 | 100 | const { controller: Controller } = toComponent(decorators)(Component, bindings); 101 | const ctrl = new Controller($element, $injector); 102 | 103 | const WrappedComponent = ctrl.WrappedComponent; 104 | const tree = renderer.create( 105 | 106 | ).toJSON(); 107 | 108 | expect(tree).toMatchSnapshot(); 109 | }); 110 | 111 | test('creates renderer', () => { 112 | const decorators = [ 113 | () => (render) => ({render()}), 114 | () => (render) => ({render()}) 115 | ]; 116 | const Component = (props) => ({props.title}); 117 | const bindings = { title: '<' }; 118 | 119 | const element = document.createElement('div'); 120 | const $element = [ element ]; 121 | const $injectInvoke = (factory) => factory(); 122 | const $injector = { invoke: $injectInvoke }; 123 | 124 | const { controller: Controller } = toComponent(decorators)(Component, bindings); 125 | const ctrl = new Controller($element, $injector); 126 | const WrappedComponent = ctrl.WrappedComponent; 127 | 128 | expect(createRenderer.mock.calls).toHaveLength(1); 129 | 130 | const call = createRenderer.mock.calls[0]; 131 | expect(call).toEqual([ WrappedComponent, element ]); 132 | }); 133 | 134 | test('$onChanges pass props to render function correctly', () => { 135 | const decorators = [ 136 | () => (render) => ({render()}), 137 | () => (render) => ({render()}) 138 | ]; 139 | const Component = (props) => ({props.title}); 140 | const bindings = { title: '<' }; 141 | const scope = { title: 'MyTitle', unknownProp: 'unknown' }; 142 | const props = { title: scope.title }; 143 | 144 | const element = document.createElement('div'); 145 | const $element = [ element ]; 146 | const $injectInvoke = (factory) => factory(); 147 | const $injector = { invoke: $injectInvoke }; 148 | 149 | const { controller: Controller } = toComponent(decorators)(Component, bindings); 150 | const ctrl = new Controller($element, $injector); 151 | // We manually bind the scope the the controller 152 | ctrl.title = scope.title; 153 | ctrl.unknownProp = scope.unknownProp; 154 | // Mock render function 155 | const render = jest.fn(); 156 | ctrl.renderer.render = render; 157 | 158 | 159 | ctrl.$onChanges(); 160 | expect(render.mock.calls).toHaveLength(1); 161 | expect(render.mock.calls[0]).toEqual([ props ]); 162 | }); 163 | 164 | test('$onDestroy unmounts correctly', () => { 165 | const decorators = [ 166 | () => (render) => ({render()}), 167 | () => (render) => ({render()}) 168 | ]; 169 | const Component = (props) => ({props.title}); 170 | const bindings = { title: '<' }; 171 | 172 | const element = document.createElement('div'); 173 | const $element = [ element ]; 174 | const $injectInvoke = (factory) => factory(); 175 | const $injector = { invoke: $injectInvoke }; 176 | 177 | const { controller: Controller } = toComponent(decorators)(Component, bindings); 178 | const ctrl = new Controller($element, $injector); 179 | // Mock unmount function 180 | const unmount = jest.fn(); 181 | ctrl.renderer.unmount = unmount; 182 | 183 | 184 | ctrl.$onDestroy(); 185 | expect(unmount.mock.calls).toHaveLength(1); 186 | }); 187 | }); 188 | 189 | describe('AngularJS component', () => { 190 | const createComponentRenderer = (template, componentName, component) => { 191 | const componentNameInTemplate = componentName.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); 192 | 193 | let $compile; 194 | let $scope; 195 | 196 | angular.mock.module(($compileProvider) => { 197 | $compileProvider.component(componentName, component); 198 | }); 199 | angular.mock.inject((_$compile_, _$rootScope_) => { 200 | $compile = _$compile_; 201 | $scope = _$rootScope_.$new(); 202 | }); 203 | 204 | const render = () => { 205 | const wrapper = $compile(template)($scope); 206 | wrapper.toElement = () => wrapper.children()[0]; 207 | 208 | return wrapper; 209 | }; 210 | 211 | return { 212 | $scope: $scope, 213 | render: render 214 | }; 215 | }; 216 | 217 | test('renders correctly without props', () => { 218 | const Component = () => (MyComponent); 219 | const ngComponent = toComponent()(Component, {}); 220 | 221 | const renderer = createComponentRenderer( 222 | '', 223 | 'reactComponentWithoutProps', 224 | ngComponent 225 | ); 226 | 227 | expect(renderer.render().toElement()).toMatchSnapshot(); 228 | }); 229 | 230 | test('renders correctly with props', () => { 231 | const Component = (props) => ({props.title}); 232 | const ngComponent = toComponent()(Component, { title: '<' }); 233 | 234 | const renderer = createComponentRenderer( 235 | '', 236 | 'reactComponentWithProps', 237 | ngComponent 238 | ); 239 | const $scope = renderer.$scope; 240 | const wrapper = renderer.render(); 241 | 242 | $scope.title = 'My 1st Title'; 243 | $scope.$digest(); 244 | 245 | expect(wrapper.toElement()).toMatchSnapshot(); 246 | 247 | 248 | $scope.title = 'My 2nd Title'; 249 | $scope.$digest(); 250 | 251 | expect(wrapper.toElement()).toMatchSnapshot(); 252 | }); 253 | }); 254 | }); 255 | }); 256 | -------------------------------------------------------------------------------- /src/__tests__/renderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { createRenderer } from '../renderer' 4 | 5 | jest.mock('react-dom', () => ({ 6 | render: jest.fn(), 7 | unmountComponentAtNode: jest.fn() 8 | })) 9 | 10 | describe('createRenderer', () => { 11 | test('creates render and unmount function', () => { 12 | const Component = () => (); 13 | const container = document.createElement('div'); 14 | 15 | const renderer = createRenderer(Component, container); 16 | 17 | expect(typeof renderer).toBe('object'); 18 | expect(typeof renderer.render).toBe('function'); 19 | expect(typeof renderer.unmount).toBe('function'); 20 | }); 21 | 22 | describe('render', () => { 23 | afterEach(() => { 24 | ReactDOM.render.mockClear(); 25 | }); 26 | 27 | test('renders correct component into container', () => { 28 | const Component = () => (); 29 | const container = document.createElement('div'); 30 | const props = { title: 'MyTitle' }; 31 | 32 | const { render } = createRenderer(Component, container) 33 | 34 | 35 | render(); 36 | expect(ReactDOM.render.mock.calls).toHaveLength(1); 37 | 38 | const call = ReactDOM.render.mock.calls[0]; 39 | expect(call[0].type).toBe(Component); 40 | expect(call[1]).toBe(container); 41 | }); 42 | 43 | test('renders correctly without props', () => { 44 | const Component = () => (); 45 | const container = document.createElement('div'); 46 | 47 | const { render } = createRenderer(Component, container); 48 | 49 | 50 | render(); 51 | const element = ReactDOM.render.mock.calls[0][0]; 52 | expect(element.props).toEqual({}); 53 | }); 54 | 55 | test('renders correctly with props', () => { 56 | const Component = () => (); 57 | const container = document.createElement('div'); 58 | const props = { title: 'MyTitle' }; 59 | 60 | const { render } = createRenderer(Component, container); 61 | 62 | 63 | render(props); 64 | const element = ReactDOM.render.mock.calls[0][0]; 65 | expect(element.props).toEqual(props); 66 | }); 67 | }); 68 | 69 | test('unmount function unmounts correctly', () => { 70 | const Component = () => (); 71 | const container = document.createElement('div'); 72 | 73 | const { unmount } = createRenderer(Component, container); 74 | 75 | 76 | unmount(container); 77 | expect(ReactDOM.unmountComponentAtNode.mock.calls).toHaveLength(1); 78 | 79 | const call = ReactDOM.unmountComponentAtNode.mock.calls[0]; 80 | expect(call[0]).toBe(container); 81 | 82 | 83 | ReactDOM.render.mockClear(); 84 | ReactDOM.unmountComponentAtNode.mockClear(); 85 | }); 86 | }); -------------------------------------------------------------------------------- /src/__tests__/utils.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import { parseBindingDefinition, createMapScopeToProps, wrapWithDecorators } from '../utils'; 5 | 6 | describe('parseBindingDefinition', () => { 7 | test('parse binding definitions correctly', () => { 8 | expect(parseBindingDefinition('<')).toEqual({ binding: '<', optional: false }); 9 | expect(parseBindingDefinition('&')).toEqual({ binding: '&', optional: false }); 10 | 11 | // Optional 12 | expect(parseBindingDefinition(' { 25 | expect(() => { 26 | parseBindingDefinition(':'); 27 | }).toThrowError(`Defintion of binding ':' is not a valid`); 28 | }); 29 | 30 | test('throws an error for unsupported bindings', () => { 31 | expect(() => { 32 | parseBindingDefinition('@'); 33 | }).toThrowError(`Binding '@' is not a supported binding`); 34 | }); 35 | }); 36 | 37 | describe('createMapScopeToProps', () => { 38 | test('throws errors for invalid or not supported bindings', () => { 39 | expect(() => { 40 | createMapScopeToProps({ 41 | name: ':' 42 | }); 43 | }).toThrowError(`Defintion of binding ':' is not a valid`); 44 | 45 | expect(() => { 46 | createMapScopeToProps({ 47 | name: '@' 48 | }); 49 | }).toThrowError(`Binding '@' is not a supported binding`); 50 | }); 51 | 52 | test('create mapScopeToProps functions ', () => { 53 | const bindings = { 54 | name: '<', 55 | onClickName: '&' 56 | }; 57 | 58 | const mapScopeToProps = createMapScopeToProps(bindings); 59 | 60 | expect(typeof mapScopeToProps).toBe('function'); 61 | }); 62 | test('mapScopeToProps returns correct props', () => { 63 | const bindings = { 64 | name: '<', 65 | onClickName: '&' 66 | }; 67 | const scope = { 68 | name: 'Hello', 69 | onClickName: jest.fn(), 70 | unknownProp: 'UNKNOWN' 71 | } 72 | const event = Symbol(); 73 | const mapScopeToProps = createMapScopeToProps(bindings); 74 | 75 | const props = mapScopeToProps(scope); 76 | 77 | expect(props.name).toBe(scope.name); 78 | expect(typeof props.onClickName).toBe('function'); 79 | 80 | props.onClickName(event) 81 | expect(scope.onClickName.mock.calls[0]).toEqual([ { $event: event } ]); 82 | }); 83 | 84 | test('mapScopeToProps handles optional bindings correctly', () => { 85 | const bindings = { 86 | name: ' { 110 | test('returns function', () => { 111 | const decorators = []; 112 | 113 | expect(typeof wrapWithDecorators()).toBe('function'); 114 | }); 115 | 116 | describe('renders correctly', () => { 117 | const decorators = [ 118 | (render) => ({render()}), 119 | (render) => ({render()}) 120 | ]; 121 | const ComponentToDecorate = ({ content }) => ({content}); 122 | 123 | const Component = wrapWithDecorators(decorators)(ComponentToDecorate); 124 | 125 | const tree = renderer.create( 126 | 127 | ).toJSON(); 128 | 129 | expect(tree).toMatchSnapshot(); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { createMapScopeToProps, wrapWithDecorators } from './utils'; 2 | import { createRenderer } from './renderer'; 3 | 4 | function toComponent (decorators) { 5 | decorators = decorators || []; 6 | 7 | function createComponent (Component, bindings) { 8 | bindings = bindings || {}; 9 | 10 | const mapScopeToProps = createMapScopeToProps(bindings); 11 | 12 | class ComponentController { 13 | constructor ($element, $injector) { 14 | this.$injector = $injector; 15 | 16 | this.WrappedComponent = wrapWithDecorators(this.resolveDecorators(decorators))(Component); 17 | 18 | this.renderer = createRenderer(this.WrappedComponent, $element[0]); 19 | } 20 | 21 | resolveDecorators (decorators) { 22 | return decorators.map((decorator) => this.$injector.invoke(decorator)); 23 | } 24 | 25 | $onChanges () { 26 | const props = mapScopeToProps(this); 27 | 28 | this.renderer.render(props); 29 | } 30 | 31 | $onDestroy () { 32 | this.renderer.unmount(); 33 | } 34 | } 35 | 36 | ComponentController.$inject = ['$element', '$injector']; 37 | 38 | return { 39 | bindings: bindings, 40 | controller: ComponentController 41 | }; 42 | }; 43 | 44 | // Exposed for testing 45 | createComponent._decorators = decorators; 46 | 47 | return createComponent; 48 | } 49 | 50 | export { 51 | toComponent 52 | }; 53 | -------------------------------------------------------------------------------- /src/renderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | export function createRenderer (Component, container) { 5 | function render (props) { 6 | ReactDOM.render(, container); 7 | }; 8 | 9 | function unmount () { 10 | ReactDOM.unmountComponentAtNode(container) 11 | }; 12 | 13 | return { 14 | render, 15 | unmount 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function parseBindingDefinition (bindingDefinition) { 4 | const match = bindingDefinition.match(/^([@=<&])(\??)/); 5 | 6 | if (!match) { 7 | throw new Error(`Defintion of binding '${bindingDefinition}' is not a valid`); 8 | } 9 | 10 | const binding = match[1]; 11 | const optional = match[2] === '?'; 12 | 13 | // We only support '<' and '&' 14 | if (binding !== '<' && binding !== '&') { 15 | throw new Error(`Binding '${binding}' is not a supported binding`); 16 | } 17 | 18 | return { binding, optional }; 19 | } 20 | 21 | export function createMapScopeToProps (bindings) { 22 | // We compile the details as early as possible because we want to validate the bindings before using the bindings. 23 | const bindingDetails = Object.keys(bindings).reduce((details, name) => { 24 | details[name] = parseBindingDefinition(bindings[name]); 25 | 26 | return details; 27 | }, {}); 28 | 29 | 30 | return function mapScopeToProps (scope) { 31 | return Object.keys(bindingDetails).reduce((props, propName) => { 32 | const { binding, optional } = bindingDetails[propName]; 33 | 34 | // If the binding is optional and no value is passed we don't want to add it to the props 35 | if (optional && typeof scope[propName] === 'undefined') { 36 | return props; 37 | } 38 | 39 | // We expect that properties which are bound with & are functions 40 | if (binding === '&') { 41 | props[propName] = (event) => scope[propName]({ $event: event }); 42 | } else { 43 | props[propName] = scope[propName]; 44 | } 45 | 46 | return props; 47 | }, {}); 48 | }; 49 | } 50 | 51 | export function wrapWithDecorators (decorators) { 52 | const compose = (render, decorate) => { 53 | // We create a new render function which takes props from outside 54 | const renderDecorator = (props) => { 55 | // Function which takes the passed props to render the component 56 | const renderComponentWithProps = () => render(props); 57 | 58 | // Pass the render function for component rendering to the decorate function 59 | // as render function 60 | return decorate(renderComponentWithProps); 61 | }; 62 | 63 | return renderDecorator; 64 | }; 65 | 66 | return function withDecorators (Component) { 67 | const render = (props) => (); 68 | 69 | return decorators.reduce(compose, render); 70 | }; 71 | } 72 | --------------------------------------------------------------------------------