diff --git a/app/angular/src/client/preview/angular-beta/RendererService.test.ts b/app/angular/src/client/preview/angular-beta/RendererService.test.ts index 3f6dbb067b8..3e1767beb27 100644 --- a/app/angular/src/client/preview/angular-beta/RendererService.test.ts +++ b/app/angular/src/client/preview/angular-beta/RendererService.test.ts @@ -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 () => { diff --git a/app/angular/src/client/preview/angular-beta/RendererService.ts b/app/angular/src/client/preview/angular-beta/RendererService.ts index 16231c5f290..5ed6e54a26b 100644 --- a/app/angular/src/client/preview/angular-beta/RendererService.ts +++ b/app/angular/src/client/preview/angular-beta/RendererService.ts @@ -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; - 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(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(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; } } diff --git a/examples/angular-cli/.storybook/preview.ts b/examples/angular-cli/.storybook/preview.ts index 1f2220621e7..5bc63229587 100644 --- a/examples/angular-cli/.storybook/preview.ts +++ b/examples/angular-cli/.storybook/preview.ts @@ -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: '한국어' }, + ], + }, + }, }; diff --git a/examples/angular-cli/src/stories/addons/toolbars/locales/__snapshots__/locales.stories.storyshot b/examples/angular-cli/src/stories/addons/toolbars/locales/__snapshots__/locales.stories.storyshot new file mode 100644 index 00000000000..2a9f53e9864 --- /dev/null +++ b/examples/angular-cli/src/stories/addons/toolbars/locales/__snapshots__/locales.stories.storyshot @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Addons / Toolbars / Locales With Angular Service 1`] = ` + + Your locale is en
I say: Hello +
+`; diff --git a/examples/angular-cli/src/stories/addons/toolbars/locales/locales.stories.ts b/examples/angular-cli/src/stories/addons/toolbars/locales/locales.stories.ts new file mode 100644 index 00000000000..f15d5f895ff --- /dev/null +++ b/examples/angular-cli/src/stories/addons/toolbars/locales/locales.stories.ts @@ -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 = (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 }}
+ I say: {{ 'hello' | translate }} + `, + props: { + locale, + }, + }; +}; diff --git a/examples/angular-cli/src/stories/addons/toolbars/locales/translate.pipe.ts b/examples/angular-cli/src/stories/addons/toolbars/locales/translate.pipe.ts new file mode 100644 index 00000000000..7ededc6aabe --- /dev/null +++ b/examples/angular-cli/src/stories/addons/toolbars/locales/translate.pipe.ts @@ -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)}`; + } +} diff --git a/examples/angular-cli/src/stories/addons/toolbars/locales/translate.service.ts b/examples/angular-cli/src/stories/addons/toolbars/locales/translate.service.ts new file mode 100644 index 00000000000..b30544f9d6f --- /dev/null +++ b/examples/angular-cli/src/stories/addons/toolbars/locales/translate.service.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable, InjectionToken, Optional } from '@angular/core'; + +export const DEFAULT_LOCALE = new InjectionToken('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; + } +} diff --git a/examples/angular-cli/src/stories/core/parameters/__snapshots__/all-parameters.stories.storyshot b/examples/angular-cli/src/stories/core/parameters/__snapshots__/all-parameters.stories.storyshot index 69a63403adc..3a7f55044ab 100644 --- a/examples/angular-cli/src/stories/core/parameters/__snapshots__/all-parameters.stories.storyshot +++ b/examples/angular-cli/src/stories/core/parameters/__snapshots__/all-parameters.stories.storyshot @@ -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",