├── .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 
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('')).toEqual({ binding: '<', optional: true });
13 | expect(parseBindingDefinition('&?')).toEqual({ binding: '&', optional: true });
14 |
15 | // Named
16 | 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: '',
87 | onClickName: '&?',
88 | onDoubleClickName: '&?'
89 | };
90 | const scope = {
91 | name: 'Hello',
92 | onDoubleClickName: jest.fn()
93 | };
94 | const event = Symbol();
95 | const mapScopeToProps = createMapScopeToProps(bindings);
96 |
97 | const props = mapScopeToProps(scope);
98 |
99 | expect(props.name).toBe(scope.name);
100 | expect(typeof props.onClickName).toBe('undefined');
101 | expect(typeof props.onDoubleClickName).toBe('function');
102 |
103 | props.onDoubleClickName(event)
104 | expect(scope.onDoubleClickName.mock.calls[0]).toEqual([ { $event: event } ]);
105 | });
106 | });
107 |
108 |
109 | describe('wrapWithDecorators', () => {
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 |
--------------------------------------------------------------------------------