-
-
Notifications
You must be signed in to change notification settings - Fork 9.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Angular and Vue storyshots #2564
Changes from 66 commits
97c2b42
edb1cf6
c208016
e0bae92
91f17b6
8f549fa
714b694
43a273b
d9942cf
2c460e4
887182f
9cffe0a
29e84bd
f9e83ca
b09b631
3c782a6
3802a8e
3cbd772
db89374
7193eee
36daf9c
5ba2e9e
84237fb
4b612eb
796deb5
7fc4888
08cbbc8
51b91d8
347bdc2
637a919
a0d4e10
ea2db19
a1fd3cf
6a5b3b8
c35d48b
3bb8ab9
45c7f24
ee1fad4
a7a6324
3aa07d0
279090c
7730d21
acfd41e
23b85f0
c4b7ecf
6115fe1
0b57690
de37c4b
08ec56b
e304771
227aaf5
8eee1c0
2aac993
11687bd
b0f4064
ea9adf3
c3b5aa7
805f73e
2e30203
a5eb21c
9ba4b74
64506b4
cb3029e
2ca902a
c2ced8d
9091f4a
d17f61b
f68a410
d5b30aa
5fcf200
878aa75
53b9a3a
89af8fe
db75151
760d3c7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,6 @@ root = true | |
[*] | ||
end_of_line = lf | ||
|
||
[*.{js,json,ts}] | ||
[*.{js,json,ts,vue}] | ||
indent_style = space | ||
indent_size = 2 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) | ||
|
||
 | ||
|
||
|
@@ -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 install it, so you need to install it separately (read [here](#deps-issue) why). | ||
|
||
```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 install it, so you need to install it separately (read [here](#deps-issue) why). | ||
|
||
```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 install it, so you need yo install it separately (read [here](#deps-issue) why). | ||
|
||
```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. | ||
|
||
`dependancies` - 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). | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
`optionalDependencies` - behaves like a regular dependency, but do not fail the installation in case there is a problem to bring the dep. | ||
|
||
`peerDependancies` - listing all the deps in peer will trigger warnings during the installation - we don't want users to install unneeded deps by hand. | ||
|
||
`optionalPeerDependancies` - unfortunately there is nothing like this =( | ||
|
||
For more information read npm [docs](https://docs.npmjs.com/files/package.json#dependencies) | ||
|
||
## Configure Storyshots | ||
|
||
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)). | ||
|
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); | ||
} | ||
} | ||
} |
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'); |
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, | ||
}; | ||
}; |
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, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
// eslint-disable-next-line import/no-extraneous-dependencies | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How are people expected to get |
||
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; |
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; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a super-nit, but I prefer when a sentence keeps making sense if you replace links with their text, that's why I don't really like links with "here" text.
Can we just make "doesn't" a link?