diff --git a/app/angular/index.d.ts b/app/angular/index.d.ts index 1cbe7beb3bd..96e06d24596 100644 --- a/app/angular/index.d.ts +++ b/app/angular/index.d.ts @@ -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; component: any }; diff --git a/app/angular/src/client/preview/angular/components/app.component.ts b/app/angular/src/client/preview/angular/components/app.component.ts index 7595655eded..5f9dffe3534 100644 --- a/app/angular/src/client/preview/angular/components/app.component.ts +++ b/app/angular/src/client/preview/angular/components/app.component.ts @@ -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: "" + selector: 'app-root', + 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 = (props)[key]; - const property = (instance)[key]; + this.setProps(instance, this.data); + } - if (!(property instanceof EventEmitter)) { - (instance)[key] = (props)[key]; - } else if (typeof value === 'function') { - property.subscribe((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 = _.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); } -} \ No newline at end of file + + /** + * 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); + } + } +} diff --git a/app/angular/src/client/preview/angular/helpers.ts b/app/angular/src/client/preview/angular/helpers.ts index cfbb3f7639f..4870f525ff1 100644 --- a/app/angular/src/client/preview/angular/helpers.ts +++ b/app/angular/src/client/preview/angular/helpers.ts @@ -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>[] = []; @@ -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); -}); \ No newline at end of file +}); diff --git a/app/angular/src/client/preview/angular/types.ts b/app/angular/src/client/preview/angular/types.ts index 9302e85de66..7109bcf1226 100644 --- a/app/angular/src/client/preview/angular/types.ts +++ b/app/angular/src/client/preview/angular/types.ts @@ -4,11 +4,13 @@ export interface NgModuleMetadata { schemas: Array, providers: Array, } - + +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 \ No newline at end of file +export type IGetStoryWithContext = (context: IContext) => NgStory diff --git a/app/angular/tsconfig.json b/app/angular/tsconfig.json index 3a462063a0e..1b9dd767251 100644 --- a/app/angular/tsconfig.json +++ b/app/angular/tsconfig.json @@ -4,6 +4,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "skipLibCheck": true, + "noImplicitAny": true, "lib": [ "es2016", "dom" diff --git a/examples/angular-cli/src/stories/customControlValueAccessor/custom-cva-component.stories.ts b/examples/angular-cli/src/stories/customControlValueAccessor/custom-cva-component.stories.ts new file mode 100644 index 00000000000..214235d7be1 --- /dev/null +++ b/examples/angular-cli/src/stories/customControlValueAccessor/custom-cva-component.stories.ts @@ -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') + } + }))); \ No newline at end of file diff --git a/examples/angular-cli/src/stories/customControlValueAccessor/custom-cva.component.ts b/examples/angular-cli/src/stories/customControlValueAccessor/custom-cva.component.ts new file mode 100644 index 00000000000..095c5254938 --- /dev/null +++ b/examples/angular-cli/src/stories/customControlValueAccessor/custom-cva.component.ts @@ -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: ` +
{{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; + } +} diff --git a/examples/angular-cli/src/stories/index.ts b/examples/angular-cli/src/stories/index.ts index d755b489884..9ed48fc6784 100644 --- a/examples/angular-cli/src/stories/index.ts +++ b/examples/angular-cli/src/stories/index.ts @@ -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, diff --git a/package.json b/package.json index 8b5619ea5fd..6b4c8c1d4a2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 184d317ae77..d084ec7921b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"