Skip to content

Commit

Permalink
Merge pull request #2564 from storybooks/angular-storyshots
Browse files Browse the repository at this point in the history
Angular and Vue storyshots
  • Loading branch information
igor-dv authored Jan 15, 2018
2 parents 48a92e1 + 760d3c7 commit ebe0ca8
Show file tree
Hide file tree
Showing 53 changed files with 2,386 additions and 139 deletions.
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ root = true
[*]
end_of_line = lf

[*.{js,json,ts,html}]
[*.{js,json,ts,vue,html}]
indent_style = space
indent_size = 2
65 changes: 65 additions & 0 deletions addons/storyshots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ StoryShots adds automatic Jest Snapshot Testing for [Storybook](https://storyboo
This addon works with Storybook for:
- [React](https://github.com/storybooks/storybook/tree/master/app/react)
- [React Native](https://github.com/storybooks/storybook/tree/master/app/react-native)
- [Angular](https://github.com/storybooks/storybook/tree/master/app/angular)
- [Vue](https://github.com/storybooks/storybook/tree/master/app/vue)

![StoryShots In Action](docs/storyshots-fail.png)

Expand All @@ -36,6 +38,69 @@ Usually, you might already have completed this step. If not, here are some resou

> Note: If you use React 16, you'll need to follow [these additional instructions](https://github.com/facebook/react/issues/9102#issuecomment-283873039).
### Configure Jest for React
StoryShots addon for React is dependent on [react-test-renderer](https://github.com/facebook/react/tree/master/packages/react-test-renderer), but
[doesn't](#deps-issue) install it, so you need to install it separately.

```sh
npm install --save-dev react-test-renderer
```

### Configure Jest for Angular
StoryShots addon for Angular is dependent on [jest-preset-angular](https://github.com/thymikee/jest-preset-angular), but
[doesn't](#deps-issue) install it, so you need to install it separately.

```sh
npm install --save-dev jest-preset-angular
```

If you already use Jest for testing your angular app - probably you already have the needed jest configuration.
Anyway you can add these lines to your jest config:
```js
module.exports = {
globals: {
__TRANSFORM_HTML__: true,
},
transform: {
'^.+\\.jsx?$': 'babel-jest',
'^.+\\.(ts|html)$': '<rootDir>/node_modules/jest-preset-angular/preprocessor.js',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node', '.html'],
};
```
### Configure Jest for Vue
StoryShots addon for Vue is dependent on [jest-vue-preprocessor](https://github.com/vire/jest-vue-preprocessor), but
[doesn't](#deps-issue) install it, so you need yo install it separately.

```sh
npm install --save-dev jest-vue-preprocessor
```

If you already use Jest for testing your vue app - probably you already have the needed jest configuration.
Anyway you can add these lines to your jest config:
```js
module.exports = {
transform: {
'^.+\\.jsx?$': 'babel-jest',
'.*\\.(vue)$': '<rootDir>/node_modules/jest-vue-preprocessor',
},
moduleFileExtensions: ['vue', 'js', 'jsx', 'json', 'node'],
};
```

### <a name="deps-issue"></a>Why don't we install dependencies of each framework ?
Storyshots addon is currently supporting React, Angular and Vue. Each framework needs its own packages to be integrated with Jest. We don't want people that use only React will need to bring other dependencies that do not make sense for them.

`dependencies` - will installed an exact version of the particular dep - Storyshots can work with different versions of the same framework (let's say React v16 and React v15), that have to be compatible with a version of its plugin (react-test-renderer).

`optionalDependencies` - behaves like a regular dependency, but do not fail the installation in case there is a problem to bring the dep.

`peerDependencies` - listing all the deps in peer will trigger warnings during the installation - we don't want users to install unneeded deps by hand.

`optionalPeerDependencies` - unfortunately there is nothing like this =(

For more information read npm [docs](https://docs.npmjs.com/files/package.json#dependencies)

## Configure Storyshots for HTML snapshots

Create a new test file with the name `Storyshots.test.js`. (Or whatever the name you prefer, as long as it matches Jest's config [`testMatch`](http://facebook.github.io/jest/docs/en/configuration.html#testmatch-array-string)).
Expand Down
6 changes: 2 additions & 4 deletions addons/storyshots/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
},
"scripts": {
"build-storybook": "build-storybook",
"prepare": "babel ./src --out-dir ./dist",
"prepare": "node ../../scripts/prepare.js",
"storybook": "start-storybook -p 6006",
"example": "jest storyshot.test"
},
Expand Down Expand Up @@ -43,8 +43,6 @@
},
"peerDependencies": {
"@storybook/addons": "^3.4.0-alpha.4",
"babel-core": "^6.26.0 || ^7.0.0-0",
"react": "*",
"react-test-renderer": "*"
"babel-core": "^6.26.0 || ^7.0.0-0"
}
}
92 changes: 92 additions & 0 deletions addons/storyshots/src/angular/app.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// We could use NgComponentOutlet here but there's currently no easy way
// 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 {
Component,
Inject,
OnInit,
ViewChild,
ViewContainerRef,
ComponentFactoryResolver,
OnDestroy,
EventEmitter,
SimpleChanges,
SimpleChange,
} from '@angular/core';
import { STORY } from './app.token';
import { NgStory, ICollection } from './types';

@Component({
selector: 'storybook-dynamic-app-root',
template: '<ng-template #target></ng-template>',
})
export class AppComponent implements OnInit, OnDestroy {
@ViewChild('target', { read: ViewContainerRef })
target: ViewContainerRef;
constructor(private cfr: ComponentFactoryResolver, @Inject(STORY) private data: NgStory) {}

ngOnInit(): void {
this.putInMyHtml();
}

ngOnDestroy(): void {
this.target.clear();
}

private putInMyHtml(): void {
this.target.clear();
const compFactory = this.cfr.resolveComponentFactory(this.data.component);
const instance = this.target.createComponent(compFactory).instance;

this.setProps(instance, this.data);
}

/**
* Set inputs and outputs
*/
private setProps(instance: any, { props = {} }: NgStory): void {
const changes: SimpleChanges = {};
const hasNgOnChangesHook = !!instance['ngOnChanges'];

Object.keys(props).map((key: string) => {
const value = props[key];
const instanceProperty = instance[key];

if (!(instanceProperty instanceof EventEmitter) && !!value) {
instance[key] = value;
if (hasNgOnChangesHook) {
changes[key] = new SimpleChange(undefined, value, instanceProperty === undefined);
}
} else if (typeof value === 'function' && 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 (!!Object.keys(changes).length) {
instance.ngOnChanges(changes);
}
}

/**
* If component implements ControlValueAccessor interface try to set ngModel
*/
private setNgModel(instance: any, props: ICollection): void {
if (!!props['ngModel']) {
instance.writeValue(props.ngModel);
}

if (typeof props.ngModelChange === 'function') {
instance.registerOnChange(props.ngModelChange);
}
}
}
4 changes: 4 additions & 0 deletions addons/storyshots/src/angular/app.token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { InjectionToken } from '@angular/core';
import { NgStory } from './types';

export const STORY = new InjectionToken<NgStory>('story');
64 changes: 64 additions & 0 deletions addons/storyshots/src/angular/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Component, Type } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { STORY } from './app.token';
import { NgStory } from './types';

const getModuleMeta = (
declarations: Array<Type<any> | any[]>,
entryComponents: Array<Type<any> | any[]>,
bootstrap: Array<Type<any> | any[]>,
data: NgStory,
moduleMetadata: any
) => {
return {
declarations: [...declarations, ...(moduleMetadata.declarations || [])],
imports: [BrowserModule, FormsModule, ...(moduleMetadata.imports || [])],
providers: [
{ provide: STORY, useValue: Object.assign({}, data) },
...(moduleMetadata.providers || []),
],
entryComponents: [...entryComponents, ...(moduleMetadata.entryComponents || [])],
schemas: [...(moduleMetadata.schemas || [])],
bootstrap: [...bootstrap],
};
};

const createComponentFromTemplate = (template: string): Function => {
const componentClass = class DynamicComponent {};

return Component({
template: template,
})(componentClass);
};

export const initModuleData = (storyObj: NgStory): any => {
const { component, template, props, moduleMetadata = {} } = storyObj;

let AnnotatedComponent;

if (template) {
AnnotatedComponent = createComponentFromTemplate(template);
} else {
AnnotatedComponent = component;
}

const story = {
component: AnnotatedComponent,
props,
};

const moduleMeta = getModuleMeta(
[AppComponent, AnnotatedComponent],
[AnnotatedComponent],
[AppComponent],
story,
moduleMetadata
);

return {
AppComponent,
moduleMeta,
};
};
43 changes: 43 additions & 0 deletions addons/storyshots/src/angular/loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import runWithRequireContext from '../require_context';
import hasDependency from '../hasDependency';
import loadConfig from '../config-loader';

function setupAngularJestPreset() {
// Angular + Jest + Storyshots = Crazy Shit:
// We need to require 'jest-preset-angular/setupJest' before any storybook code
// is running inside jest - one of the things that `jest-preset-angular/setupJest` does is
// extending the `window.Reflect` with all the needed metadata functions, that are required
// for emission of the TS decorations like 'design:paramtypes'
require.requireActual('jest-preset-angular/setupJest');
}

function test(options) {
return (
options.framework === 'angular' || (!options.framework && hasDependency('@storybook/angular'))
);
}

function load(options) {
setupAngularJestPreset();

const { content, contextOpts } = loadConfig({
configDirPath: options.configPath,
babelConfigPath: '@storybook/angular/dist/server/babel_config',
});

runWithRequireContext(content, contextOpts);

return {
framework: 'angular',
renderTree: require.requireActual('./renderTree').default,
renderShallowTree: () => {
throw new Error('Shallow renderer is not supported for angular');
},
storybook: require.requireActual('@storybook/angular'),
};
}

export default {
load,
test,
};
44 changes: 44 additions & 0 deletions addons/storyshots/src/angular/renderTree.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import AngularSnapshotSerializer from 'jest-preset-angular/AngularSnapshotSerializer';
// eslint-disable-next-line import/no-extraneous-dependencies
import HTMLCommentSerializer from 'jest-preset-angular/HTMLCommentSerializer';
// eslint-disable-next-line import/no-extraneous-dependencies
import { TestBed } from '@angular/core/testing';
// eslint-disable-next-line import/no-extraneous-dependencies
import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing';
// eslint-disable-next-line import/no-extraneous-dependencies
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { addSerializer } from 'jest-specific-snapshot';
import { initModuleData } from './helpers.ts';

addSerializer(HTMLCommentSerializer);
addSerializer(AngularSnapshotSerializer);

function getRenderedTree(story, context) {
const currentStory = story.render(context);

const { moduleMeta, AppComponent } = initModuleData(currentStory);

TestBed.configureTestingModule({
imports: [...moduleMeta.imports],
declarations: [...moduleMeta.declarations],
providers: [...moduleMeta.providers],
schemas: [NO_ERRORS_SCHEMA, ...moduleMeta.schemas],
bootstrap: [...moduleMeta.bootstrap],
});

TestBed.overrideModule(BrowserDynamicTestingModule, {
set: {
entryComponents: [...moduleMeta.entryComponents],
},
});

return TestBed.compileComponents().then(() => {
const tree = TestBed.createComponent(AppComponent);
tree.detectChanges();

return tree;
});
}

export default getRenderedTree;
19 changes: 19 additions & 0 deletions addons/storyshots/src/angular/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface NgModuleMetadata {
declarations?: Array<any>;
entryComponents?: Array<any>;
imports?: Array<any>;
schemas?: Array<any>;
providers?: Array<any>;
}

export interface ICollection {
[p: string]: any;
}

export interface NgStory {
component?: any;
props: ICollection;
propsMeta?: ICollection;
moduleMetadata?: NgModuleMetadata;
template?: string;
}
Loading

0 comments on commit ebe0ca8

Please sign in to comment.