Merge pull request #2554 from ralzinov/component-props-assignment-improvements

Component props assignment improvements
This commit is contained in:
Michael Shilman 2017-12-23 19:56:57 -08:00 committed by GitHub
commit c07b15a9e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 178 additions and 48 deletions

View File

@ -1,4 +1,4 @@
import {NgModuleMetadata } from './dist/client/preview/angular/types';
import {NgModuleMetadata, ICollection} from './dist/client/preview/angular/types';
export interface IStorybookStory {
name: string,
@ -11,8 +11,8 @@ export interface IStoribookSection {
}
export type IGetStory = () => {
props?: {[p: string]: any};
moduleMetadata?: {[p: string]: NgModuleMetadata};
props?: ICollection;
moduleMetadata?: Partial<NgModuleMetadata>;
component: any
};

View File

@ -2,7 +2,7 @@
// to provide @Inputs and subscribe to @Outputs, see
// https://github.com/angular/angular/issues/15360
// For the time being, the ViewContainerRef approach works pretty well.
import * as _ from 'lodash';
import {
Component,
Inject,
@ -11,46 +11,86 @@ import {
ViewContainerRef,
ComponentFactoryResolver,
OnDestroy,
EventEmitter
} from "@angular/core";
import { STORY } from "../app.token";
import { NgStory } from "../types";
EventEmitter,
SimpleChanges,
SimpleChange
} from '@angular/core';
import { STORY } from '../app.token';
import { NgStory, ICollection } from '../types';
@Component({
selector: "app-root",
template: "<ng-template #target></ng-template>"
selector: 'app-root',
template: '<ng-template #target></ng-template>'
})
export class AppComponent implements AfterViewInit, OnDestroy {
@ViewChild("target", { read: ViewContainerRef })
@ViewChild('target', { read: ViewContainerRef })
target: ViewContainerRef;
constructor(
private cfr: ComponentFactoryResolver,
@Inject(STORY) private data: NgStory
) {}
ngAfterViewInit() {
ngAfterViewInit(): void {
this.putInMyHtml();
}
ngOnDestroy() {
ngOnDestroy(): void {
this.target.clear();
}
putInMyHtml() {
private putInMyHtml(): void {
this.target.clear();
const { component, props = {}, propsMeta = {} } = this.data;
let compFactory = this.cfr.resolveComponentFactory(component);
const compFactory = this.cfr.resolveComponentFactory(this.data.component);
const instance = this.target.createComponent(compFactory).instance;
Object.keys(propsMeta).map(key => {
const value = (<any>props)[key];
const property = (<any>instance)[key];
this.setProps(instance, this.data);
}
if (!(property instanceof EventEmitter)) {
(<any>instance)[key] = (<any>props)[key];
} else if (typeof value === 'function') {
property.subscribe((<any>props)[key]);
/**
* Set inputs and outputs
*/
private setProps(instance: any, {props = {}, propsMeta = {}}: NgStory): void {
const changes: SimpleChanges = {};
const hasNgOnChangesHook = _.has(instance, 'ngOnChanges');
_.forEach(propsMeta, (meta, key) => {
const value = props[key];
const instanceProperty = <any>_.get(instance, key);
if (!(instanceProperty instanceof EventEmitter) && !_.isUndefined(value)) {
_.set(instance, key, value);
if (hasNgOnChangesHook) {
changes[key] = new SimpleChange(undefined, value, instanceProperty === undefined);
}
} else if (_.isFunction(value) && (key !== 'ngModelChange')) {
instanceProperty.subscribe(value);
}
});
this.callNgOnChangesHook(instance, changes);
this.setNgModel(instance, props);
}
}
/**
* Manually call 'ngOnChanges' hook because angular doesn't do that for dynamic components
* Issue: [https://github.com/angular/angular/issues/8903]
*/
private callNgOnChangesHook(instance: any, changes: SimpleChanges): void {
if (!_.isEmpty(changes)) {
_.invoke(instance, 'ngOnChanges', changes);
}
}
/**
* If component implements ControlValueAccessor interface try to set ngModel
*/
private setNgModel(instance: any, props: ICollection): void {
if (_.has(props, 'ngModel')) {
_.invoke(instance, 'writeValue', props.ngModel);
}
if (_.isFunction(props.ngModelChange)) {
_.invoke(instance, 'registerOnChange', props.ngModelChange);
}
}
}

View File

@ -4,16 +4,17 @@ import {
NgModule,
Component,
NgModuleRef
} from "@angular/core";
} from '@angular/core';
import {FormsModule} from '@angular/forms'
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { BrowserModule } from "@angular/platform-browser";
import { AppComponent } from "./components/app.component";
import { ErrorComponent } from "./components/error.component";
import { NoPreviewComponent } from "./components/no-preview.component";
import { STORY } from "./app.token";
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './components/app.component';
import { ErrorComponent } from './components/error.component';
import { NoPreviewComponent } from './components/no-preview.component';
import { STORY } from './app.token';
import { getAnnotations, getParameters, getPropMetadata } from './utils';
import { NgModuleMetadata, NgStory, IGetStoryWithContext, IContext, NgProvidedData } from "./types";
import { NgModuleMetadata, NgStory, IGetStoryWithContext, IContext, NgProvidedData } from './types';
let platform: any = null;
let promises: Promise<NgModuleRef<any>>[] = [];
@ -37,15 +38,19 @@ const debounce = (func: IRenderStoryFn | IRenderErrorFn,
immediate: boolean = false): () => void => {
let timeout: any;
return function () {
let context = this, args = arguments;
let later = function () {
const context = this, args = arguments;
const later = function () {
timeout = null;
if (!immediate) func.apply(context, args);
if (!immediate) {
func.apply(context, args);
}
};
let callNow = immediate && !timeout;
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
if (callNow) {
func.apply(context, args);
}
};
};
@ -57,8 +62,9 @@ const getComponentMetadata = (
providers: []
} }: NgStory
) => {
if (!component || typeof component !== "function")
throw new Error("No valid component provided");
if (!component || typeof component !== 'function') {
throw new Error('No valid component provided');
}
const componentMetadata = getAnnotations(component)[0] || {};
const propsMetadata = getPropMetadata(component);
@ -118,7 +124,7 @@ moduleMetadata: NgModuleMetadata = {
}): IModule => {
const moduleMeta = new NgModule({
declarations: [...declarations, ...moduleMetadata.declarations],
imports: [BrowserModule, ...moduleMetadata.imports],
imports: [BrowserModule, FormsModule, ...moduleMetadata.imports],
providers: [{ provide: STORY, useValue: Object.assign({}, data) }, ...moduleMetadata.providers],
entryComponents: [...entryComponents],
schemas: [...moduleMetadata.schemas],
@ -140,7 +146,9 @@ const initModule = (currentStory: IGetStoryWithContext, context: IContext, reRen
moduleMeta
} = getComponentMetadata(currentStory(context));
if (!componentMeta) throw new Error("No component metadata available");
if (!componentMeta) {
throw new Error('No component metadata available');
}
const AnnotatedComponent = getAnnotatedComponent(
componentMeta,
@ -176,9 +184,9 @@ const draw = (newModule: IModule, reRender: boolean = true): void => {
Promise.all(promises)
.then((modules) => {
modules.forEach(mod => mod.destroy());
const body = document.body;
const app = document.createElement("app-root");
const app = document.createElement('app-root');
body.appendChild(app);
promises = [];
promises.push(platform.bootstrapModule(newModule));
@ -213,4 +221,4 @@ export const renderNoPreview = debounce(() => {
export const renderNgApp = debounce((story, context, reRender) => {
draw(initModule(story, context, reRender), reRender);
});
});

View File

@ -4,11 +4,13 @@ export interface NgModuleMetadata {
schemas: Array<any>,
providers: Array<any>,
}
export interface ICollection {[p: string]: any}
export interface NgStory {
component: any,
props: {[p: string]: any},
propsMeta: {[p: string]: any},
props: ICollection,
propsMeta: ICollection,
moduleMetadata?: NgModuleMetadata
}
@ -23,4 +25,4 @@ export interface IContext {
[p: string]: any
}
export type IGetStoryWithContext = (context: IContext) => NgStory
export type IGetStoryWithContext = (context: IContext) => NgStory

View File

@ -4,6 +4,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"noImplicitAny": true,
"lib": [
"es2016",
"dom"

View File

@ -0,0 +1,17 @@
import {storiesOf} from '@storybook/angular';
import {action} from '@storybook/addon-actions';
import {withNotes} from '@storybook/addon-notes';
import {CustomCvaComponent} from './custom-cva.component';
const description = `
This is an example of component that implements ControlValueAccessor interface
`;
storiesOf('ngModel', module)
.add('custom ControlValueAccessor', withNotes(description)(() => ({
component: CustomCvaComponent,
props: {
ngModel: 'Type anything',
ngModelChange: action('ngModelChnange')
}
})));

View File

@ -0,0 +1,55 @@
import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
const NOOP = () => {};
@Component({
selector: 'custom-cva-component',
template: `
<div>{{value}}</div>
<input type="text" [(ngModel)]="value" />
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomCvaComponent),
multi: true,
}
]
})
export class CustomCvaComponent implements ControlValueAccessor {
disabled: boolean;
protected onChange: (value: any) => void = NOOP;
protected onTouch: () => void = NOOP;
protected internalValue: any;
get value(): any {
return this.internalValue;
}
set value(value: any) {
if (value !== this.internalValue) {
this.internalValue = value;
this.onChange(value);
}
}
writeValue(value: any): void {
if (value !== this.internalValue) {
this.internalValue = value;
}
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouch = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
}

View File

@ -25,6 +25,8 @@ import { ServiceComponent } from './moduleMetadata/service.component'
import { NameComponent } from './name.component';
import { CustomPipePipe } from './custom.pipe';
import './customControlValueAccessor/custom-cva-component.stories';
storiesOf('Welcome', module)
.add('to Storybook', () => ({
component: Welcome,

View File

@ -31,6 +31,7 @@
"test-latest-cra": "npm --prefix lib/cli run test-latest-cra"
},
"devDependencies": {
"@types/lodash": "^4.14.91",
"babel-cli": "^6.26.0",
"babel-core": "^6.26.0",
"babel-eslint": "^8.0.3",

View File

@ -260,6 +260,10 @@
version "2.5.54"
resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-2.5.54.tgz#a6b5f2ae2afb6e0307774e8c7c608e037d491c63"
"@types/lodash@^4.14.91":
version "4.14.91"
resolved "https://artifactory.iponweb.net:443/artifactory/api/npm/npm/@types/lodash/-/lodash-4.14.91.tgz#794611b28056d16b5436059c6d800b39d573cd3a"
"@types/mz@0.0.32":
version "0.0.32"
resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.32.tgz#e8248b4e41424c052edc1725dd33650c313a3659"