From b7560432263f51a3e74772096619d7c868bad7f0 Mon Sep 17 00:00:00 2001 From: Ben Grynhaus Date: Sat, 30 Mar 2019 16:27:39 +0300 Subject: [PATCH] Allow wrapping any React component with an Angular one on-the-fly. (#106) --- apps/demo/src/app/app.component.html | 5 + apps/demo/src/app/app.component.ts | 6 + apps/demo/src/app/app.module.ts | 14 +- apps/demo/src/app/counter/react-counter.tsx | 27 ++++ apps/demo/tsconfig.app.json | 3 +- .../lib/components/generic-wrap-component.ts | 131 ++++++++++++++++++ .../src/lib/components/wrapper-component.ts | 26 ++-- libs/core/src/lib/declarations/known-keys.ts | 3 + libs/core/src/lib/declarations/many.ts | 3 + .../object/{to-object.ts => from-pairs.ts} | 2 +- .../src/lib/utils/object}/omit.ts | 2 +- libs/core/src/public-api.ts | 5 +- .../button/base-button.component.ts | 3 +- .../command-bar/command-bar.component.ts | 3 +- .../details-list/details-list.component.ts | 3 +- .../hover-card/hover-card.component.ts | 3 +- .../base-picker/base-picker.component.ts | 5 +- .../search-box/search-box.component.ts | 3 +- .../tooltip/tooltip-host.component.ts | 3 +- tsconfig.json | 1 + 20 files changed, 220 insertions(+), 31 deletions(-) create mode 100644 apps/demo/src/app/counter/react-counter.tsx create mode 100644 libs/core/src/lib/components/generic-wrap-component.ts rename libs/core/src/lib/utils/object/{to-object.ts => from-pairs.ts} (70%) rename libs/{fabric/src/lib/utils => core/src/lib/utils/object}/omit.ts (92%) diff --git a/apps/demo/src/app/app.component.html b/apps/demo/src/app/app.component.html index 9764fe9..ccf44aa 100644 --- a/apps/demo/src/app/app.component.html +++ b/apps/demo/src/app/app.component.html @@ -9,6 +9,11 @@ +
Generic React component wrapper
+ +
test
+
+
diff --git a/apps/demo/src/app/app.component.ts b/apps/demo/src/app/app.component.ts index a9d86a5..c312319 100644 --- a/apps/demo/src/app/app.component.ts +++ b/apps/demo/src/app/app.component.ts @@ -178,6 +178,12 @@ export class AppComponent { this.onDecrement = this.onDecrement.bind(this); } + count = 3; + + reactCustomOnIncrement(newCount: number) { + this.count = newCount; + } + customItemCount = 1; // FIXME: Allow declarative syntax too diff --git a/apps/demo/src/app/app.module.ts b/apps/demo/src/app/app.module.ts index 3bae67a..75ff7b1 100644 --- a/apps/demo/src/app/app.module.ts +++ b/apps/demo/src/app/app.module.ts @@ -1,4 +1,4 @@ -import { AngularReactBrowserModule } from '@angular-react/core'; +import { AngularReactBrowserModule, wrapComponent } from '@angular-react/core'; import { FabBreadcrumbModule, FabButtonModule, @@ -35,11 +35,18 @@ import { FabSpinButtonModule, FabTextFieldModule, } from '@angular-react/fabric'; -import { NgModule } from '@angular/core'; +import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core'; import { NxModule } from '@nrwl/nx'; import { initializeIcons } from 'office-ui-fabric-react/lib/Icons'; import { AppComponent } from './app.component'; import { CounterComponent } from './counter/counter.component'; +import { CounterProps, Counter } from './counter/react-counter'; + +const MyCounterComponent = wrapComponent({ + ReactComponent: Counter, + selector: 'my-counter', + // propNames: ['count', 'onIncrement'], // needed if propTypes are not defined on `ReactComponent` +}); @NgModule({ imports: [ @@ -80,8 +87,9 @@ import { CounterComponent } from './counter/counter.component'; FabSpinButtonModule, FabTextFieldModule, ], - declarations: [AppComponent, CounterComponent], + declarations: [AppComponent, CounterComponent, MyCounterComponent], bootstrap: [AppComponent], + schemas: [NO_ERRORS_SCHEMA], }) export class AppModule { constructor() { diff --git a/apps/demo/src/app/counter/react-counter.tsx b/apps/demo/src/app/counter/react-counter.tsx new file mode 100644 index 0000000..a2c0898 --- /dev/null +++ b/apps/demo/src/app/counter/react-counter.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import * as PropTypes from 'prop-types'; + +export interface CounterProps { + count?: number; + onIncrement?: (count: number) => void; +} + +export const Counter: React.FC = ({ count = 0, onIncrement, children, ...rest } = {}) => { + return ( + + ); +}; + +Counter.propTypes = { + count: PropTypes.number, + onIncrement: PropTypes.func, +}; diff --git a/apps/demo/tsconfig.app.json b/apps/demo/tsconfig.app.json index 46debef..fade5b3 100644 --- a/apps/demo/tsconfig.app.json +++ b/apps/demo/tsconfig.app.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc/apps/demo", - "module": "es2015" + "module": "es2015", + "jsx": "react" }, "include": ["**/*.ts"], "exclude": ["**/*.spec.ts", "src/test.ts"] diff --git a/libs/core/src/lib/components/generic-wrap-component.ts b/libs/core/src/lib/components/generic-wrap-component.ts new file mode 100644 index 0000000..912cccf --- /dev/null +++ b/libs/core/src/lib/components/generic-wrap-component.ts @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as React from 'react'; +import { + Component, + ElementRef, + ViewChild, + ChangeDetectionStrategy, + Input, + ChangeDetectorRef, + Renderer2, + NgZone, + Output, + EventEmitter, + Type, +} from '@angular/core'; + +declare const __decorate: typeof import('tslib').__decorate; + +import { ReactWrapperComponent } from './wrapper-component'; +import { registerElement } from '../renderer/registry'; + +export interface WrapComponentOptions { + /** + * The type of the component to wrap. + */ + ReactComponent: React.ComponentType; + + /** + * The selector to use. + */ + selector: string; + + /** + * The prop names to pass to the `reactComponent`, if any. + * Note that any prop starting with `on` will be converted to an `Output`, and other to `Input`s. + * + * @note If `reactComponent` has `propTypes`, this can be omitted. + */ + propNames?: string[]; + + /** + * @see `WrapperComponentOptions#setHostDisplay`. + */ + setHostDisplay?: boolean; + + /** + * An _optional_ callback for specified wether a prop should be considered an `Output`. + * @default propName => propName.startsWith('on') + */ + isOutputProp?: (propName: string) => boolean; +} + +/** + * Gets the display name of a component. + * @param WrappedComponent The type of the wrapper component + */ +function getDisplayName(WrappedComponent: React.ComponentType): string { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; +} + +/** + * Checks if the propName is an output one. + * Currently uses a simple check - anything that starts with `on` is considered an output prop. + */ +function defaultIsOutputProp(propName: string): boolean { + return propName.startsWith('on'); +} + +function getPropNames(ReactComponent: React.ComponentType) { + if (!ReactComponent.propTypes) { + return null; + } + + return Object.keys(ReactComponent.propTypes); +} + +/** + * Wrap a React component with an Angular one. + * + * @template TProps The type of props of the underlying React element. + * @param options Options for wrapping the component. + * @returns A class of a wrapper Angular component. + */ +export function wrapComponent( + options: Readonly> +): Type> { + const Tag = getDisplayName(options.ReactComponent); + registerElement(Tag, () => options.ReactComponent); + + const propNames = options.propNames || getPropNames(options.ReactComponent); + const isOutputProp = options.isOutputProp || defaultIsOutputProp; + + const inputProps = propNames.filter(propName => !isOutputProp(propName)); + const outputProps = propNames.filter(isOutputProp); + + const inputPropsBindings = inputProps.map(propName => `[${propName}]="${propName}"`); + const outputPropsBindings = outputProps.map(propName => `(${propName})="${propName}.emit($event)"`); + const propsBindings = [...inputPropsBindings, ...outputPropsBindings].join('\n'); + + @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + styles: ['react-renderer'], + selector: options.selector, + template: ` + <${Tag} + #reactNode + ${propsBindings} + > + + + `, + }) + class WrapperComponent extends ReactWrapperComponent { + @ViewChild('reactNode') protected reactNodeRef: ElementRef; + + constructor(elementRef: ElementRef, changeDetectorRef: ChangeDetectorRef, renderer: Renderer2, ngZone: NgZone) { + super(elementRef, changeDetectorRef, renderer, { ngZone, setHostDisplay: options.setHostDisplay }); + + outputProps.forEach(propName => { + this[propName] = new EventEmitter(); + }); + } + } + + inputProps.forEach(propName => __decorate([Input()], WrapperComponent.prototype, propName)); + outputProps.forEach(propName => __decorate([Output()], WrapperComponent.prototype, propName)); + + return WrapperComponent; +} diff --git a/libs/core/src/lib/components/wrapper-component.ts b/libs/core/src/lib/components/wrapper-component.ts index bc97cf1..283371f 100644 --- a/libs/core/src/lib/components/wrapper-component.ts +++ b/libs/core/src/lib/components/wrapper-component.ts @@ -12,6 +12,7 @@ import { Renderer2, SimpleChanges, AfterContentInit, + ɵBaseDef, } from '@angular/core'; import classnames from 'classnames'; import toStyle from 'css-to-style'; @@ -21,9 +22,10 @@ import { Many } from '../declarations/many'; import { ReactContentProps } from '../renderer/react-content'; import { isReactNode } from '../renderer/react-node'; import { isReactRendererData } from '../renderer/renderer'; -import { toObject } from '../utils/object/to-object'; +import { fromPairs } from '../utils/object/from-pairs'; import { afterRenderFinished } from '../utils/render/render-delay'; import { InputRendererOptions, JsxRenderFunc, createInputJsxRenderer, createRenderPropHandler } from './render-props'; +import { omit } from '../utils/object/omit'; // Forbidden attributes are still ignored, since they may be set from the wrapper components themselves (forbidden is only applied for users of the wrapper components) const ignoredAttributeMatchers = [/^_?ng-?.*/, /^style$/, /^class$/]; @@ -231,17 +233,23 @@ export abstract class ReactWrapperComponent implements AfterC ); const eventListeners = this.elementRef.nativeElement.getEventListeners(); + // Event listeners already being handled natively by the derived component + const handledEventListeners = Object.keys( + ((this.constructor as any).ngBaseDef as ɵBaseDef).outputs + ) as (keyof typeof eventListeners)[]; + const unhandledEventListeners = omit(eventListeners, ...handledEventListeners); + const eventHandlersProps = - eventListeners && Object.keys(eventListeners).length - ? toObject( - Object.values(eventListeners).map<[string, React.EventHandler]>(([eventListener]) => [ - eventListener.type, - (ev: React.SyntheticEvent) => eventListener.listener(ev && ev.nativeEvent), - ]) + unhandledEventListeners && Object.keys(unhandledEventListeners).length + ? fromPairs( + Object.values(unhandledEventListeners).map<[string, React.EventHandler]>( + ([eventListener]) => [ + eventListener.type, + (ev: React.SyntheticEvent) => eventListener.listener(ev && ev.nativeEvent), + ] + ) ) : {}; - { - } this.reactNodeRef.nativeElement.setProperties({ ...props, ...eventHandlersProps }); } diff --git a/libs/core/src/lib/declarations/known-keys.ts b/libs/core/src/lib/declarations/known-keys.ts index 1b99978..b495321 100644 --- a/libs/core/src/lib/declarations/known-keys.ts +++ b/libs/core/src/lib/declarations/known-keys.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + // prettier-ignore /** * Get the known keys (i.e. no index signature) of T. diff --git a/libs/core/src/lib/declarations/many.ts b/libs/core/src/lib/declarations/many.ts index bb631ea..00e68e2 100644 --- a/libs/core/src/lib/declarations/many.ts +++ b/libs/core/src/lib/declarations/many.ts @@ -1 +1,4 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + export type Many = T | T[]; diff --git a/libs/core/src/lib/utils/object/to-object.ts b/libs/core/src/lib/utils/object/from-pairs.ts similarity index 70% rename from libs/core/src/lib/utils/object/to-object.ts rename to libs/core/src/lib/utils/object/from-pairs.ts index ed1472e..99dc6fb 100644 --- a/libs/core/src/lib/utils/object/to-object.ts +++ b/libs/core/src/lib/utils/object/from-pairs.ts @@ -1,7 +1,7 @@ /** * Transforms an array of [key, value] tuples to an object */ -export function toObject(pairs: T): object { +export function fromPairs(pairs: T): object { return pairs.reduce( (acc, [key, value]) => Object.assign(acc, { diff --git a/libs/fabric/src/lib/utils/omit.ts b/libs/core/src/lib/utils/object/omit.ts similarity index 92% rename from libs/fabric/src/lib/utils/omit.ts rename to libs/core/src/lib/utils/object/omit.ts index 02c8b00..d7a5181 100644 --- a/libs/fabric/src/lib/utils/omit.ts +++ b/libs/core/src/lib/utils/object/omit.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { Omit } from '@angular-react/core'; +import { Omit } from '../../declarations/omit'; /** * Omit a a set of properties from an object. diff --git a/libs/core/src/public-api.ts b/libs/core/src/public-api.ts index 63b7418..f23bc2c 100644 --- a/libs/core/src/public-api.ts +++ b/libs/core/src/public-api.ts @@ -3,12 +3,15 @@ export { AngularReactBrowserModule } from './lib/angular-react-browser.module'; export * from './lib/components/wrapper-component'; +export * from './lib/components/generic-wrap-component'; export * from './lib/declarations/public-api'; export * from './lib/renderer/components/Disguise'; export { getPassProps, passProp, PassProp } from './lib/renderer/pass-prop-decorator'; export { createReactContentElement, ReactContent, ReactContentProps } from './lib/renderer/react-content'; export * from './lib/renderer/react-template'; -export { registerElement } from './lib/renderer/registry'; +export { registerElement, ComponentResolver } from './lib/renderer/registry'; + +export * from './lib/utils/object/omit'; export { JsxRenderFunc, RenderComponentOptions, diff --git a/libs/fabric/src/lib/components/button/base-button.component.ts b/libs/fabric/src/lib/components/button/base-button.component.ts index e43cd76..31d4af9 100644 --- a/libs/fabric/src/lib/components/button/base-button.component.ts +++ b/libs/fabric/src/lib/components/button/base-button.component.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { InputRendererOptions, JsxRenderFunc, ReactWrapperComponent } from '@angular-react/core'; +import { InputRendererOptions, JsxRenderFunc, ReactWrapperComponent, omit } from '@angular-react/core'; import { ChangeDetectorRef, ElementRef, @@ -23,7 +23,6 @@ import { IContextualMenuItem } from 'office-ui-fabric-react'; import { Subscription } from 'rxjs'; import { CommandBarItemChangedPayload } from '../command-bar/directives/command-bar-item.directives'; import { mergeItemChanges } from '../core/declarative/item-changed'; -import { omit } from '../../utils/omit'; import { getDataAttributes } from '../../utils/get-data-attributes'; export abstract class FabBaseButtonComponent extends ReactWrapperComponent diff --git a/libs/fabric/src/lib/components/command-bar/command-bar.component.ts b/libs/fabric/src/lib/components/command-bar/command-bar.component.ts index 3c5358a..809b1fd 100644 --- a/libs/fabric/src/lib/components/command-bar/command-bar.component.ts +++ b/libs/fabric/src/lib/components/command-bar/command-bar.component.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { InputRendererOptions, KnownKeys, ReactWrapperComponent } from '@angular-react/core'; +import { InputRendererOptions, KnownKeys, ReactWrapperComponent, omit } from '@angular-react/core'; import { AfterContentInit, ChangeDetectionStrategy, @@ -22,7 +22,6 @@ import { ICommandBarItemProps, ICommandBarProps } from 'office-ui-fabric-react/l import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu'; import { Subscription } from 'rxjs'; import { OnChanges, TypedChanges } from '../../declarations/angular/typed-changes'; -import omit from '../../utils/omit'; import { mergeItemChanges } from '../core/declarative/item-changed'; import { CommandBarItemChangedPayload, CommandBarItemDirective } from './directives/command-bar-item.directives'; import { diff --git a/libs/fabric/src/lib/components/details-list/details-list.component.ts b/libs/fabric/src/lib/components/details-list/details-list.component.ts index 3ad42d6..b3c9663 100644 --- a/libs/fabric/src/lib/components/details-list/details-list.component.ts +++ b/libs/fabric/src/lib/components/details-list/details-list.component.ts @@ -18,7 +18,7 @@ import { Renderer2, ViewChild, } from '@angular/core'; -import { InputRendererOptions, JsxRenderFunc, ReactWrapperComponent } from '@angular-react/core'; +import { InputRendererOptions, JsxRenderFunc, ReactWrapperComponent, omit } from '@angular-react/core'; import { DetailsListBase, IColumn, @@ -32,7 +32,6 @@ import { IListProps } from 'office-ui-fabric-react/lib/List'; import { Subscription } from 'rxjs'; import { OnChanges, TypedChanges } from '../../declarations/angular/typed-changes'; -import { omit } from '../../utils/omit'; import { mergeItemChanges } from '../core/declarative/item-changed'; import { ChangeableItemsDirective } from '../core/shared/changeable-items.directive'; import { IDetailsListColumnOptions } from './directives/details-list-column.directive'; diff --git a/libs/fabric/src/lib/components/hover-card/hover-card.component.ts b/libs/fabric/src/lib/components/hover-card/hover-card.component.ts index 04add79..f5aff0f 100644 --- a/libs/fabric/src/lib/components/hover-card/hover-card.component.ts +++ b/libs/fabric/src/lib/components/hover-card/hover-card.component.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { InputRendererOptions, Omit, ReactWrapperComponent } from '@angular-react/core'; +import { InputRendererOptions, Omit, ReactWrapperComponent, omit } from '@angular-react/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -15,7 +15,6 @@ import { ViewChild, } from '@angular/core'; import { IExpandingCardProps, IHoverCardProps, IPlainCardProps } from 'office-ui-fabric-react/lib/HoverCard'; -import { omit } from '../../utils/omit'; @Component({ selector: 'fab-hover-card', diff --git a/libs/fabric/src/lib/components/pickers/base-picker/base-picker.component.ts b/libs/fabric/src/lib/components/pickers/base-picker/base-picker.component.ts index e923e46..5bc7a5c 100644 --- a/libs/fabric/src/lib/components/pickers/base-picker/base-picker.component.ts +++ b/libs/fabric/src/lib/components/pickers/base-picker/base-picker.component.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { InputRendererOptions, JsxRenderFunc, Omit, ReactWrapperComponent } from '@angular-react/core'; +import { InputRendererOptions, JsxRenderFunc, Omit, ReactWrapperComponent, omit } from '@angular-react/core'; import { ChangeDetectorRef, ElementRef, EventEmitter, Input, NgZone, OnInit, Output, Renderer2 } from '@angular/core'; import { IPersonaProps } from 'office-ui-fabric-react/lib/Persona'; import { @@ -10,7 +10,6 @@ import { IBasePickerSuggestionsProps, IPickerItemProps, } from 'office-ui-fabric-react/lib/Pickers'; -import omit from '../../../utils/omit'; export abstract class FabBasePickerComponent> extends ReactWrapperComponent @@ -19,7 +18,7 @@ export abstract class FabBasePickerComponent['resolveDelay']; @Input() defaultSelectedItems?: IBasePickerProps['defaultSelectedItems']; @Input() getTextFromItem?: IBasePickerProps['getTextFromItem']; - @Input() className?: IBasePickerProps['className']; + @Input() className?: IBasePickerProps['className']; @Input() pickerCalloutProps?: IBasePickerProps['pickerCalloutProps']; @Input() searchingText?: IBasePickerProps['searchingText']; @Input() disabled?: IBasePickerProps['disabled']; diff --git a/libs/fabric/src/lib/components/search-box/search-box.component.ts b/libs/fabric/src/lib/components/search-box/search-box.component.ts index fa1c1a8..1279107 100644 --- a/libs/fabric/src/lib/components/search-box/search-box.component.ts +++ b/libs/fabric/src/lib/components/search-box/search-box.component.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { InputRendererOptions, Omit, ReactWrapperComponent } from '@angular-react/core'; +import { InputRendererOptions, Omit, ReactWrapperComponent, omit } from '@angular-react/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -16,7 +16,6 @@ import { } from '@angular/core'; import { IButtonProps } from 'office-ui-fabric-react/lib/Button'; import { ISearchBoxProps } from 'office-ui-fabric-react/lib/SearchBox'; -import omit from '../../utils/omit'; @Component({ selector: 'fab-search-box', diff --git a/libs/fabric/src/lib/components/tooltip/tooltip-host.component.ts b/libs/fabric/src/lib/components/tooltip/tooltip-host.component.ts index 99f5d26..92a3094 100644 --- a/libs/fabric/src/lib/components/tooltip/tooltip-host.component.ts +++ b/libs/fabric/src/lib/components/tooltip/tooltip-host.component.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { InputRendererOptions, Omit, ReactWrapperComponent } from '@angular-react/core'; +import { InputRendererOptions, Omit, ReactWrapperComponent, omit } from '@angular-react/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -15,7 +15,6 @@ import { ViewChild, } from '@angular/core'; import { ITooltipHostProps, ITooltipProps } from 'office-ui-fabric-react/lib/Tooltip'; -import { omit } from '../../utils/omit'; @Component({ selector: 'fab-tooltip-host', diff --git a/tsconfig.json b/tsconfig.json index c8ba5d2..09feca5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "typeRoots": ["node_modules/@types"], "lib": ["es2017", "dom"], "baseUrl": ".", + "jsx": "react", "skipLibCheck": true, "paths": { "@angular-react/*": ["libs/*"],