feat(angular): force the rendering if the metadata structure has changed

After the first rendering of a story, the next changes of `agrs` by addon controls or `globals` (for example) are not necessarily properties of the component (or template).
but can change other things like an Angular provider
With this additional verification if the stringify `moduleMetadata` structure changes then a complete rendering is done
This commit is contained in:
ThibaudAv 2021-03-13 10:01:48 +01:00
parent ac693b3943
commit d64e49ba1a
8 changed files with 239 additions and 16 deletions

View File

@ -79,7 +79,12 @@ describe('RendererService', () => {
);
});
it('should not be re-rendered', async () => {
it('should not be re-rendered when only props change', async () => {
let countDestroy = 0;
rendererService.platform.onDestroy(() => {
countDestroy += 1;
});
// only props change
await rendererService.render({
storyFnAngular: {
@ -90,6 +95,7 @@ describe('RendererService', () => {
forced: true,
parameters: {} as any,
});
expect(countDestroy).toEqual(0);
expect(document.body.getElementsByTagName('storybook-wrapper')[0].innerHTML).toBe(
'👾: Fox'
@ -110,6 +116,43 @@ describe('RendererService', () => {
expect(document.body.getElementsByTagName('storybook-wrapper')[0].innerHTML).toBe('🍺');
});
it('should be re-rendered when moduleMetadata structure change', async () => {
let countDestroy = 0;
rendererService.platform.onDestroy(() => {
countDestroy += 1;
});
// Only props change -> no full rendering
await rendererService.render({
storyFnAngular: {
template: '{{ logo }}: {{ name }}',
props: {
logo: '🍺',
name: 'Beer',
},
},
forced: true,
parameters: {} as any,
});
expect(countDestroy).toEqual(0);
// Change in the module structure -> full rendering
await rendererService.render({
storyFnAngular: {
template: '{{ logo }}: {{ name }}',
props: {
logo: '🍺',
name: 'Beer',
},
moduleMetadata: { providers: [{ provide: 'foo', useValue: 42 }] },
},
forced: true,
parameters: {} as any,
});
expect(countDestroy).toEqual(1);
});
});
it('should properly destroy angular platform between each render', async () => {

View File

@ -1,5 +1,5 @@
/* eslint-disable no-undef */
import { enableProdMode, PlatformRef } from '@angular/core';
import { enableProdMode, NgModule, PlatformRef } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { BehaviorSubject, Subject } from 'rxjs';
@ -30,7 +30,10 @@ export class RendererService {
// Observable to change the properties dynamically without reloading angular module&component
private storyProps$: Subject<ICollection | undefined>;
private previousStoryFnAngular: StoryFnAngularReturnType = {};
private currentStoryRender: {
storyFnAngular: StoryFnAngularReturnType;
moduleMetadataSnapshot: string;
};
constructor() {
if (typeof NODE_ENV === 'string' && NODE_ENV !== 'development') {
@ -62,23 +65,28 @@ export class RendererService {
forced: boolean;
parameters: Parameters;
}) {
if (!this.fullRendererRequired(storyFnAngular, forced)) {
const storyProps$ = new BehaviorSubject<ICollection>(storyFnAngular.props);
const moduleMetadata = getStorybookModuleMetadata({ storyFnAngular, parameters }, storyProps$);
if (
!this.fullRendererRequired({
storyFnAngular,
moduleMetadata,
forced,
})
) {
this.storyProps$.next(storyFnAngular.props);
return;
}
// Complete last BehaviorSubject and create a new one for the current module
// Complete last BehaviorSubject and set a new one for the current module
if (this.storyProps$) {
this.storyProps$.complete();
}
this.storyProps$ = new BehaviorSubject<ICollection>(storyFnAngular.props);
this.storyProps$ = storyProps$;
await this.newPlatformBrowserDynamic().bootstrapModule(
createStorybookModule(
getStorybookModuleMetadata({ storyFnAngular, parameters }, this.storyProps$)
)
);
await this.newPlatformBrowserDynamic().bootstrapModule(createStorybookModule(moduleMetadata));
}
public newPlatformBrowserDynamic() {
@ -108,13 +116,42 @@ export class RendererService {
this.staticRoot.appendChild(storybookWrapperElement);
}
private fullRendererRequired(storyFnAngular: StoryFnAngularReturnType, forced: boolean) {
const { previousStoryFnAngular } = this;
this.previousStoryFnAngular = storyFnAngular;
private fullRendererRequired({
storyFnAngular,
moduleMetadata,
forced,
}: {
storyFnAngular: StoryFnAngularReturnType;
moduleMetadata: NgModule;
forced: boolean;
}) {
const { currentStoryRender: lastStoryRender } = this;
this.currentStoryRender = {
storyFnAngular,
moduleMetadataSnapshot: JSON.stringify(moduleMetadata),
};
if (
// check `forceRender` of story RenderContext
!forced ||
// if it's the first rendering and storyProps$ is not init
!this.storyProps$
) {
return true;
}
// force the rendering if the template has changed
const hasChangedTemplate =
!!storyFnAngular?.template && previousStoryFnAngular?.template !== storyFnAngular.template;
!!storyFnAngular?.template &&
lastStoryRender?.storyFnAngular?.template !== storyFnAngular.template;
if (hasChangedTemplate) {
return true;
}
return !forced || !this.storyProps$ || hasChangedTemplate;
// force the rendering if the metadata structure has changed
const hasChangedModuleMetadata =
this.currentStoryRender?.moduleMetadataSnapshot !== lastStoryRender?.moduleMetadataSnapshot;
return hasChangedModuleMetadata;
}
}

View File

@ -39,4 +39,18 @@ export const globalTypes = {
],
},
},
locale: {
name: 'Locale',
description: 'Internationalization locale',
defaultValue: 'en',
toolbar: {
icon: 'globe',
items: [
{ value: 'en', right: '🇺🇸', title: 'English' },
{ value: 'es', right: '🇪🇸', title: 'Español' },
{ value: 'zh', right: '🇨🇳', title: '中文' },
{ value: 'kr', right: '🇰🇷', title: '한국어' },
],
},
},
};

View File

@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Storyshots Addons / Toolbars / Locales With Angular Service 1`] = `
<storybook-wrapper>
Your locale is en<br /> I say: Hello
</storybook-wrapper>
`;

View File

@ -0,0 +1,48 @@
import { DecoratorFunction } from '@storybook/addons';
import { Meta, moduleMetadata } from '@storybook/angular';
import { Story } from '@storybook/angular/types-6-0';
import { TranslatePipe } from './translate.pipe';
import { DEFAULT_LOCALE } from './translate.service';
const withLocaleProvider: DecoratorFunction<unknown> = (storyFunc, context) => {
const { locale } = context.globals;
// uses `moduleMetadata` decorator to cleanly add locale provider into module metadata
// It is also possible to do it directly in story with
// ```
// const sotry = storyFunc();
// sotry.moduleMetadata = {
// ...sotry.moduleMetadata,
// providers: [
// ...(sotry.moduleMetadata?.providers ?? []),
// { provide: DEFAULT_LOCALE, useValue: locale },
// ],
// };
// return sotry;
// ```
// but more verbose
return moduleMetadata({ providers: [{ provide: DEFAULT_LOCALE, useValue: locale }] })(
storyFunc,
context
);
};
export default {
title: 'Addons / Toolbars / Locales',
decorators: [withLocaleProvider, moduleMetadata({ declarations: [TranslatePipe] })],
} as Meta;
export const WithAngularService: Story = (_args, { globals: { locale } }) => {
return {
template: `
Your locale is {{ locale }}<br>
I say: {{ 'hello' | translate }}
`,
props: {
locale,
},
};
};

View File

@ -0,0 +1,14 @@
import { Pipe, PipeTransform } from '@angular/core';
import { TranslateService } from './translate.service';
@Pipe({
name: 'translate',
})
export class TranslatePipe implements PipeTransform {
// eslint-disable-next-line no-useless-constructor
constructor(private readonly translateService: TranslateService) {}
transform(value: string): string {
return `${this.translateService.getTranslation(value)}`;
}
}

View File

@ -0,0 +1,30 @@
import { Inject, Injectable, InjectionToken, Optional } from '@angular/core';
export const DEFAULT_LOCALE = new InjectionToken<string>('test');
@Injectable({ providedIn: 'root' })
export class TranslateService {
private locale = 'en';
private translation = {
en: { hello: 'Hello' },
es: { hello: 'Hola!' },
fr: { hello: 'Bonjour!' },
kr: { hello: '안녕하세요!' },
zh: { hello: '你好!' },
};
constructor(@Inject(DEFAULT_LOCALE) defaultLocale: string | null) {
if (defaultLocale) {
this.setLocale(defaultLocale);
}
}
setLocale(locale) {
this.locale = locale;
}
getTranslation(key: string) {
return this.translation[this.locale][key] ?? key;
}
}

View File

@ -43,6 +43,36 @@ exports[`Storyshots Core / Parameters / All parameters All parameters passed to
}
]
}
},
"locale": {
"name": "Locale",
"description": "Internationalization locale",
"defaultValue": "en",
"toolbar": {
"icon": "globe",
"items": [
{
"value": "en",
"right": "🇺🇸",
"title": "English"
},
{
"value": "es",
"right": "🇪🇸",
"title": "Español"
},
{
"value": "zh",
"right": "🇨🇳",
"title": "中文"
},
{
"value": "kr",
"right": "🇰🇷",
"title": "한국어"
}
]
}
}
},
"globalParameter": "globalParameter",