diff --git a/MIGRATION.md b/MIGRATION.md index 5690bc1afac4..d56c9344c64e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -31,7 +31,7 @@ - [Addon-a11y: Removed deprecated withA11y decorator](#addon-a11y-removed-deprecated-witha11y-decorator) - [7.0 Vite changes](#70-vite-changes) - [Vite builder uses Vite config automatically](#vite-builder-uses-vite-config-automatically) - - [Vite cache moved to node_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook) + - [Vite cache moved to node\_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook) - [7.0 Webpack changes](#70-webpack-changes) - [Webpack4 support discontinued](#webpack4-support-discontinued) - [Babel mode v7 exclusively](#babel-mode-v7-exclusively) @@ -40,6 +40,7 @@ - [7.0 Framework-specific changes](#70-framework-specific-changes) - [Angular: Drop support for Angular \< 14](#angular-drop-support-for-angular--14) - [Angular: Drop support for calling Storybook directly](#angular-drop-support-for-calling-storybook-directly) + - [Angular: Application providers and ModuleWithProviders](#angular-application-providers-and-modulewithproviders) - [Angular: Removed legacy renderer](#angular-removed-legacy-renderer) - [Next.js: use the `@storybook/nextjs` framework](#nextjs-use-the-storybooknextjs-framework) - [SvelteKit: needs the `@storybook/sveltekit` framework](#sveltekit-needs-the-storybooksveltekit-framework) @@ -76,7 +77,7 @@ - [Dropped addon-docs manual babel configuration](#dropped-addon-docs-manual-babel-configuration) - [Dropped addon-docs manual configuration](#dropped-addon-docs-manual-configuration) - [Autoplay in docs](#autoplay-in-docs) - - [Removed STORYBOOK_REACT_CLASSES global](#removed-storybook_react_classes-global) + - [Removed STORYBOOK\_REACT\_CLASSES global](#removed-storybook_react_classes-global) - [parameters.docs.source.excludeDecorators defaults to true](#parametersdocssourceexcludedecorators-defaults-to-true) - [7.0 Deprecations and default changes](#70-deprecations-and-default-changes) - [storyStoreV7 enabled by default](#storystorev7-enabled-by-default) @@ -928,6 +929,47 @@ Starting in 7.0, we drop support for Angular < 14 In Storybook 6.4 we have deprecated calling Storybook directly (`npm run storybook`) for Angular. In Storybook 7.0, we've removed it entirely. Instead you have to set up the Storybook builder in your `angular.json` and execute `ng run :storybook` to start Storybook. Please visit https://github.com/storybookjs/storybook/tree/next/code/frameworks/angular to set up Storybook for Angular correctly. +#### Angular: Application providers and ModuleWithProviders + +In Storybook 7.0 we use the new bootstrapApplication API to bootstrap a standalone component to the DOM. The component is configured in a way to respect your configured imports, declarations and schemas, which you can define via the `moduleMetadata` decorator imported from `@storybook/angular`. + +This means also, that there is no root ngModule anymore. Previously you were able to add ModuleWithProviders, likely the result of a 'Module.forRoot()'-style call, to your 'imports' array of the moduleMetadata definition. This is now discouraged. Instead, you should use the `applicationConfig` decorator to add your application-wide providers. These providers will be passed to the bootstrapApplication function. + +For example, if you want to configure BrowserAnimationModule in your stories, please extract the necessary providers the following way and provide them via the `applicationConfig` decorator: + +```js +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { importProvidersFrom } from '@angular/core'; +import { applicationConfig } from '@storybook/angular'; + +export default { + title: 'Example', + decorators: [ + applicationConfig({ + providers: [importProvidersFrom(BrowserAnimationsModule)], + } + ], +}; +``` + +You can also use the `provide-style` decorator to provide an application-wide service: + +```js +import { provideAnimations } from '@angular/platform-browser/animations'; +import { moduleMetadata } from '@storybook/angular'; + +export default { + title: 'Example', + decorators: [ + applicationConfig({ + providers: [provideAnimations()], + }), + ], +}; +``` + +Please visit https://angular.io/guide/standalone-components#configuring-dependency-injection for more information. + #### Angular: Removed legacy renderer The `parameters.angularLegacyRendering` option is removed. You cannot use the old legacy renderer anymore. @@ -1430,7 +1472,6 @@ This was a legacy global variable from the early days of react docgen. If you we By default we don't render decorators in the Source/Canvas blocks. If you want to render decorators, you can set the parameter to `false`. - ### 7.0 Deprecations and default changes #### storyStoreV7 enabled by default diff --git a/code/frameworks/angular/README.md b/code/frameworks/angular/README.md index 356d7c66e694..f08ad06614f0 100644 --- a/code/frameworks/angular/README.md +++ b/code/frameworks/angular/README.md @@ -7,6 +7,8 @@ - [Setup Compodoc](#setup-compodoc) - [Automatic setup](#automatic-setup) - [Manual setup](#manual-setup) + - [moduleMetadata decorator](#modulemetadata-decorator) + - [applicationConfig decorator](#applicationconfig-decorator) - [FAQ](#faq) - [How do I migrate to a Angular Storybook builder?](#how-do-i-migrate-to-a-angular-storybook-builder) - [Do you have only one Angular project in your workspace?](#do-you-have-only-one-angular-project-in-your-workspace) @@ -165,6 +167,91 @@ const preview: Preview = { export default preview; ``` +## moduleMetadata decorator + +If your component has dependencies on other Angular directives and modules, these can be supplied using the moduleMetadata decorator either for all stories or for individual stories. + +```js +import { StoryFn, Meta, moduleMetadata } from '@storybook/angular'; +import { SomeComponent } from './some.component'; + +export default { + component: SomeComponent, + decorators: [ + // Apply metadata to all stories + moduleMetadata({ + // import necessary ngModules or standalone components + imports: [...], + // declare components that are used in the template + declarations: [...], + // List of providers that should be available to the root component and all its children. + providers: [...], + }), + ], +} as Meta; + +const Template = (): StoryFn => (args) => ({ + props: args, +}); + +export const Base = Template(); + +export const WithCustomProvider = Template(); +WithCustomProvider.decorators = [ + // Apply metadata to a specific story + moduleMetadata({ + imports: [...], + declarations: [...], + providers: [...] + }), +]; +``` + +## applicationConfig decorator + +If your component relies on application-wide providers, like the ones defined by BrowserAnimationsModule or any other modules which use the forRoot pattern to provide a ModuleWithProviders, you can use the applicationConfig decorator to provide them to the [bootstrapApplication function](https://angular.io/guide/standalone-components#configuring-dependency-injection), which we use to bootstrap the component in Storybook. + +```js + +import { StoryFn, Meta, applicationConfig } from '@storybook/angular'; +import { BrowserAnimationsModule, provideAnimations } from '@angular/platform-browser/animations'; +import { importProvidersFrom } from '@angular/core'; +import { ChipsModule } from './angular-src/chips.module'; + +export default { + component: ChipsGroupComponent, + decorators: [ + // Apply application config to all stories + applicationConfig({ + // List of providers and environment providers that should be available to the root component and all its children. + providers: [ + ... + // Import application-wide providers from a module + importProvidersFrom(BrowserAnimationsModule) + // Or use provide-style functions if available instead, e.g. + provideAnimations() + ], + }), + ], +} as Meta; + +const Template = (): StoryFn => (args) => ({ + props: args, +}); + +export const Base = Template(); + +export const WithCustomApplicationProvider = Template(); + +WithCustomApplicationProvider.decorators = [ + // Apply application config to a specific story + // The configuration will not be merged with the global configuration defined in the export default block + applicationConfig({ + providers: [...] + }), +]; +``` + ## FAQ ### How do I migrate to a Angular Storybook builder? diff --git a/code/frameworks/angular/src/client/angular-beta/AbstractRenderer.ts b/code/frameworks/angular/src/client/angular-beta/AbstractRenderer.ts index 2f43e502c160..029de1cd02e6 100644 --- a/code/frameworks/angular/src/client/angular-beta/AbstractRenderer.ts +++ b/code/frameworks/angular/src/client/angular-beta/AbstractRenderer.ts @@ -124,22 +124,21 @@ export abstract class AbstractRenderer { const analyzedMetadata = new PropertyExtractor(storyFnAngular.moduleMetadata, component); - const providers = [ - // Providers for BrowserAnimations & NoopAnimationsModule - analyzedMetadata.singletons, - importProvidersFrom( - ...analyzedMetadata.imports.filter((imported) => { - const { isStandalone } = PropertyExtractor.analyzeDecorators(imported); - return !isStandalone; - }) - ), - analyzedMetadata.providers, - storyPropsProvider(newStoryProps$), - ].filter(Boolean); - - const application = getApplication({ storyFnAngular, component, targetSelector }); - - const applicationRef = await bootstrapApplication(application, { providers }); + const application = getApplication({ + storyFnAngular, + component, + targetSelector, + analyzedMetadata, + }); + + const applicationRef = await bootstrapApplication(application, { + ...storyFnAngular.applicationConfig, + providers: [ + storyPropsProvider(newStoryProps$), + ...analyzedMetadata.applicationProviders, + ...(storyFnAngular.applicationConfig?.providers ?? []), + ], + }); applicationRefs.add(applicationRef); diff --git a/code/frameworks/angular/src/client/angular-beta/StorybookModule.test.ts b/code/frameworks/angular/src/client/angular-beta/StorybookModule.test.ts index 8ee95e2e4558..f77cfeb7cf2a 100644 --- a/code/frameworks/angular/src/client/angular-beta/StorybookModule.test.ts +++ b/code/frameworks/angular/src/client/angular-beta/StorybookModule.test.ts @@ -1,11 +1,11 @@ import { NgModule, Type, Component, EventEmitter, Input, Output } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { BrowserModule } from '@angular/platform-browser'; import { BehaviorSubject } from 'rxjs'; import { ICollection } from '../types'; import { getApplication } from './StorybookModule'; import { storyPropsProvider } from './StorybookProvider'; +import { PropertyExtractor } from './utils/PropertyExtractor'; describe('StorybookModule', () => { describe('getStorybookModuleMetadata', () => { @@ -55,10 +55,13 @@ describe('StorybookModule', () => { localFunction: () => 'localFunction', }; + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + const application = getApplication({ storyFnAngular: { props }, component: FooComponent, targetSelector: 'my-selector', + analyzedMetadata, }); const { fixture } = await configureTestingModule({ @@ -91,10 +94,13 @@ describe('StorybookModule', () => { }, }; + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + const application = getApplication({ storyFnAngular: { props }, component: FooComponent, targetSelector: 'my-selector', + analyzedMetadata, }); const { fixture } = await configureTestingModule({ @@ -117,10 +123,13 @@ describe('StorybookModule', () => { }; const storyProps$ = new BehaviorSubject(initialProps); + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + const application = getApplication({ storyFnAngular: { props: initialProps }, component: FooComponent, targetSelector: 'my-selector', + analyzedMetadata, }); const { fixture } = await configureTestingModule({ imports: [application], @@ -170,10 +179,13 @@ describe('StorybookModule', () => { }; const storyProps$ = new BehaviorSubject(initialProps); + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + const application = getApplication({ storyFnAngular: { props: initialProps }, component: FooComponent, targetSelector: 'my-selector', + analyzedMetadata, }); const { fixture } = await configureTestingModule({ imports: [application], @@ -208,6 +220,8 @@ describe('StorybookModule', () => { }; const storyProps$ = new BehaviorSubject(initialProps); + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + const application = getApplication({ storyFnAngular: { props: initialProps, @@ -215,6 +229,7 @@ describe('StorybookModule', () => { }, component: FooComponent, targetSelector: 'my-selector', + analyzedMetadata, }); const { fixture } = await configureTestingModule({ imports: [application], @@ -243,10 +258,13 @@ describe('StorybookModule', () => { }; const storyProps$ = new BehaviorSubject(initialProps); + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + const application = getApplication({ storyFnAngular: { props: initialProps }, component: FooComponent, targetSelector: 'my-selector', + analyzedMetadata, }); const { fixture } = await configureTestingModule({ imports: [application], @@ -276,6 +294,11 @@ describe('StorybookModule', () => { it('should display the component', async () => { const props = {}; + const analyzedMetadata = new PropertyExtractor( + { entryComponents: [WithoutSelectorComponent] }, + WithoutSelectorComponent + ); + const application = getApplication({ storyFnAngular: { props, @@ -283,6 +306,7 @@ describe('StorybookModule', () => { }, component: WithoutSelectorComponent, targetSelector: 'my-selector', + analyzedMetadata, }); const { fixture } = await configureTestingModule({ @@ -302,10 +326,13 @@ describe('StorybookModule', () => { }) class FooComponent {} + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + const application = getApplication({ storyFnAngular: { template: '' }, component: FooComponent, targetSelector: 'my-selector', + analyzedMetadata, }); const { fixture } = await configureTestingModule({ diff --git a/code/frameworks/angular/src/client/angular-beta/StorybookModule.ts b/code/frameworks/angular/src/client/angular-beta/StorybookModule.ts index 2f43390d806d..3535a85c6988 100644 --- a/code/frameworks/angular/src/client/angular-beta/StorybookModule.ts +++ b/code/frameworks/angular/src/client/angular-beta/StorybookModule.ts @@ -1,15 +1,18 @@ import { StoryFnAngularReturnType } from '../types'; import { createStorybookWrapperComponent } from './StorybookWrapperComponent'; import { computesTemplateFromComponent } from './ComputesTemplateFromComponent'; +import { PropertyExtractor } from './utils/PropertyExtractor'; export const getApplication = ({ storyFnAngular, component, targetSelector, + analyzedMetadata, }: { storyFnAngular: StoryFnAngularReturnType; component?: any; targetSelector: string; + analyzedMetadata: PropertyExtractor; }) => { const { props, styles, moduleMetadata = {} } = storyFnAngular; let { template } = storyFnAngular; @@ -22,14 +25,15 @@ export const getApplication = ({ /** * Create a component that wraps generated template and gives it props */ - return createStorybookWrapperComponent( - targetSelector, + return createStorybookWrapperComponent({ + moduleMetadata, + selector: targetSelector, template, - component, + storyComponent: component, styles, - moduleMetadata, - props - ); + initialProps: props, + analyzedMetadata, + }); }; function hasNoTemplate(template: string | null | undefined): template is undefined { diff --git a/code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts b/code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts index 6f2cbf3f932b..ea4a8767556a 100644 --- a/code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts +++ b/code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts @@ -36,23 +36,28 @@ export const componentNgModules = new Map>(); /** * Wraps the story template into a component - * - * @param storyComponent - * @param initialProps */ -export const createStorybookWrapperComponent = ( - selector: string, - template: string, - storyComponent: Type | undefined, - styles: string[], - moduleMetadata: NgModuleMetadata, - initialProps?: ICollection -): Type => { +export const createStorybookWrapperComponent = ({ + selector, + template, + storyComponent, + styles, + moduleMetadata, + initialProps, + analyzedMetadata, +}: { + selector: string; + template: string; + storyComponent: Type | undefined; + styles: string[]; + moduleMetadata: NgModuleMetadata; + initialProps?: ICollection; + analyzedMetadata?: PropertyExtractor; +}): Type => { // In ivy, a '' selector is not allowed, therefore we need to just set it to anything if // storyComponent was not provided. const viewChildSelector = storyComponent ?? '__storybook-noop'; - const analyzedMetadata = new PropertyExtractor(moduleMetadata, storyComponent); const { imports, declarations, providers } = analyzedMetadata; // Only create a new module if it doesn't already exist @@ -60,6 +65,7 @@ export const createStorybookWrapperComponent = ( // Declarations & Imports are only added once // Providers are added on every story change to allow for story-specific providers let ngModule = componentNgModules.get(storyComponent); + if (!ngModule) { @NgModule({ declarations, @@ -72,6 +78,8 @@ export const createStorybookWrapperComponent = ( ngModule = componentNgModules.get(storyComponent); } + PropertyExtractor.warnImportsModuleWithProviders(analyzedMetadata); + @Component({ selector, template, diff --git a/code/frameworks/angular/src/client/angular-beta/utils/PropertyExtractor.test.ts b/code/frameworks/angular/src/client/angular-beta/utils/PropertyExtractor.test.ts index a47509ab0add..0e2acf606372 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/PropertyExtractor.test.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/PropertyExtractor.test.ts @@ -39,9 +39,9 @@ const extractProviders = (metadata: NgModuleMetadata, component?: any) => { const { providers } = new PropertyExtractor(metadata, component); return providers; }; -const extractSingletons = (metadata: NgModuleMetadata, component?: any) => { - const { singletons } = new PropertyExtractor(metadata, component); - return singletons; +const extractApplicationProviders = (metadata: NgModuleMetadata, component?: any) => { + const { applicationProviders } = new PropertyExtractor(metadata, component); + return applicationProviders; }; describe('PropertyExtractor', () => { @@ -50,50 +50,50 @@ describe('PropertyExtractor', () => { const metadata = { imports: [BrowserModule], }; - const { imports, providers, singletons } = analyzeMetadata(metadata); + const { imports, providers, applicationProviders } = analyzeMetadata(metadata); expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule]); expect(providers.flat(Number.MAX_VALUE)).toEqual([]); - expect(singletons.flat(Number.MAX_VALUE)).toEqual([]); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual([]); }); it('should remove BrowserAnimationsModule and use its providers instead', () => { const metadata = { imports: [BrowserAnimationsModule], }; - const { imports, providers, singletons } = analyzeMetadata(metadata); + const { imports, providers, applicationProviders } = analyzeMetadata(metadata); expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule]); expect(providers.flat(Number.MAX_VALUE)).toEqual([]); - expect(singletons.flat(Number.MAX_VALUE)).toEqual(provideAnimations()); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual(provideAnimations()); }); it('should remove NoopAnimationsModule and use its providers instead', () => { const metadata = { imports: [NoopAnimationsModule], }; - const { imports, providers, singletons } = analyzeMetadata(metadata); + const { imports, providers, applicationProviders } = analyzeMetadata(metadata); expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule]); expect(providers.flat(Number.MAX_VALUE)).toEqual([]); - expect(singletons.flat(Number.MAX_VALUE)).toEqual(provideNoopAnimations()); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual(provideNoopAnimations()); }); it('should remove Browser/Animations modules recursively', () => { const metadata = { imports: [BrowserAnimationsModule, BrowserModule], }; - const { imports, providers, singletons } = analyzeMetadata(metadata); + const { imports, providers, applicationProviders } = analyzeMetadata(metadata); expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule]); expect(providers.flat(Number.MAX_VALUE)).toEqual([]); - expect(singletons.flat(Number.MAX_VALUE)).toEqual(provideAnimations()); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual(provideAnimations()); }); it('should not destructure Angular official module', () => { const metadata = { imports: [WithOfficialModule], }; - const { imports, providers, singletons } = analyzeMetadata(metadata); + const { imports, providers, applicationProviders } = analyzeMetadata(metadata); expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule, WithOfficialModule]); expect(providers.flat(Number.MAX_VALUE)).toEqual([]); - expect(singletons.flat(Number.MAX_VALUE)).toEqual([]); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual([]); }); }); @@ -146,7 +146,7 @@ describe('PropertyExtractor', () => { }); it('should return an array of singletons extracted', () => { - const singeltons = extractSingletons({ + const singeltons = extractApplicationProviders({ imports: [BrowserAnimationsModule], }); diff --git a/code/frameworks/angular/src/client/angular-beta/utils/PropertyExtractor.ts b/code/frameworks/angular/src/client/angular-beta/utils/PropertyExtractor.ts index 60e689fe6b80..d8664259e158 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/PropertyExtractor.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/PropertyExtractor.ts @@ -1,7 +1,9 @@ +/* eslint-disable no-console */ import { CommonModule } from '@angular/common'; import { Component, Directive, + importProvidersFrom, Injectable, InjectionToken, Input, @@ -18,6 +20,7 @@ import { provideAnimations, provideNoopAnimations, } from '@angular/platform-browser/animations'; +import dedent from 'ts-dedent'; import { NgModuleMetadata } from '../../types'; import { isComponentAlreadyDeclared } from './NgModulesAnalyzer'; @@ -35,18 +38,40 @@ export class PropertyExtractor implements NgModuleMetadata { declarations?: any[] = []; imports?: any[]; providers?: Provider[]; - singletons?: Provider[]; + applicationProviders?: Array>; /* eslint-enable @typescript-eslint/lines-between-class-members */ constructor(private metadata: NgModuleMetadata, private component?: any) { this.init(); } + // With the new way of mounting standalone components to the DOM via bootstrapApplication API, + // we should now pass ModuleWithProviders to the providers array of the bootstrapApplication function. + static warnImportsModuleWithProviders(propertyExtractor: PropertyExtractor) { + const hasModuleWithProvidersImport = propertyExtractor.imports.some( + (importedModule) => 'ngModule' in importedModule + ); + + if (hasModuleWithProvidersImport) { + console.warn( + dedent( + ` + Storybook Warning: + moduleMetadata property 'imports' contains one or more ModuleWithProviders, likely the result of a 'Module.forRoot()'-style call. + In Storybook 7.0 we use Angular's new 'bootstrapApplication' API to mount the component to the DOM, which accepts a list of providers to set up application-wide providers. + Use the 'applicationConfig' decorator from '@storybook/angular' to pass your ModuleWithProviders to the 'providers' property in combination with the importProvidersFrom helper function from '@angular/core' to extract all the necessary providers. + Visit https://angular.io/guide/standalone-components#configuring-dependency-injection for more information + ` + ) + ); + } + } + private init() { const analyzed = this.analyzeMetadata(this.metadata); this.imports = uniqueArray([CommonModule, analyzed.imports]); this.providers = uniqueArray(analyzed.providers); - this.singletons = uniqueArray(analyzed.singletons); + this.applicationProviders = uniqueArray(analyzed.applicationProviders); this.declarations = uniqueArray(analyzed.declarations); if (this.component) { @@ -70,7 +95,6 @@ export class PropertyExtractor implements NgModuleMetadata { * * - Removes Restricted Imports * - Extracts providers from ModuleWithProviders - * - Flattens imports * - Returns a new NgModuleMetadata object * * @@ -78,13 +102,13 @@ export class PropertyExtractor implements NgModuleMetadata { private analyzeMetadata = (metadata: NgModuleMetadata) => { const declarations = [...(metadata?.declarations || [])]; const providers = [...(metadata?.providers || [])]; - const singletons: any[] = [...(metadata?.singletons || [])]; + const applicationProviders: Provider[] = []; const imports = [...(metadata?.imports || [])].reduce((acc, imported) => { // remove ngModule and use only its providers if it is restricted // (e.g. BrowserModule, BrowserAnimationsModule, NoopAnimationsModule, ...etc) const [isRestricted, restrictedProviders] = PropertyExtractor.analyzeRestricted(imported); if (isRestricted) { - singletons.unshift(restrictedProviders || []); + applicationProviders.unshift(restrictedProviders || []); return acc; } @@ -93,32 +117,48 @@ export class PropertyExtractor implements NgModuleMetadata { return acc; }, []); - return { ...metadata, imports, providers, singletons, declarations }; + return { ...metadata, imports, providers, applicationProviders, declarations }; }; - static analyzeRestricted = (ngModule: NgModule) => { - /** - * BrowserModule is restricted, - * because bootstrapApplication API, which mounts the component to the DOM, - * automatically imports BrowserModule - */ + static analyzeRestricted = (ngModule: NgModule): [boolean] | [boolean, Provider] => { if (ngModule === BrowserModule) { + console.warn( + dedent` + Storybook Warning: + You have imported the "BrowserModule", which is not necessary anymore. + In Storybook v7.0 we are using Angular's new bootstrapApplication API to mount an Angular application to the DOM. + Note that the BrowserModule providers are automatically included when starting an application with bootstrapApplication() + Please remove the "BrowserModule" from the list of imports in your moduleMetadata definition to remove this warning. + ` + ); return [true]; } - /** - * BrowserAnimationsModule imports BrowserModule, which is restricted, - * because bootstrapApplication API, which mounts the component to the DOM, - * automatically imports BrowserModule - */ + if (ngModule === BrowserAnimationsModule) { + console.warn( + dedent` + Storybook Warning: + You have added the "BrowserAnimationsModule" to the list of "imports" in your moduleMetadata definition of your Story. + In Storybook 7.0 we use Angular's new 'bootstrapApplication' API to mount the component to the DOM, which accepts a list of providers to set up application-wide providers. + Use the 'applicationConfig' decorator from '@storybook/angular' and add the "provideAnimations" function to the list of "providers". + If your Angular version does not support "provide-like" functions, use the helper function importProvidersFrom instead to set up animations. For this case, please add "importProvidersFrom(BrowserAnimationsModule)" to the list of providers of your applicationConfig definition. + Please visit https://angular.io/guide/standalone-components#configuring-dependency-injection for more information. + ` + ); return [true, provideAnimations()]; } - /** - * NoopAnimationsModule imports BrowserModule, which is restricted, - * because bootstrapApplication API, which mounts the component to the DOM, - * automatically imports BrowserModule - */ + if (ngModule === NoopAnimationsModule) { + console.warn( + dedent` + Storybook Warning: + You have added the "NoopAnimationsModule" to the list of "imports" in your moduleMetadata definition of your Story. + In Storybook v7.0 we are using Angular's new bootstrapApplication API to mount an Angular application to the DOM, which accepts a list of providers to set up application-wide providers. + Use the 'applicationConfig' decorator from '@storybook/angular' and add the "provideNoopAnimations" function to the list of "providers". + If your Angular version does not support "provide-like" functions, use the helper function importProvidersFrom instead to set up noop animations and to extract all necessary providers from NoopAnimationsModule. For this case, please add "importProvidersFrom(NoopAnimationsModule)" to the list of providers of your applicationConfig definition. + Please visit https://angular.io/guide/standalone-components#configuring-dependency-injection for more information. + ` + ); return [true, provideNoopAnimations()]; } diff --git a/code/frameworks/angular/src/client/decorators.test.ts b/code/frameworks/angular/src/client/decorators.test.ts index 8ed4e347c55a..942768f597e1 100644 --- a/code/frameworks/angular/src/client/decorators.test.ts +++ b/code/frameworks/angular/src/client/decorators.test.ts @@ -1,7 +1,7 @@ import { Addon_StoryContext } from '@storybook/types'; import { Component } from '@angular/core'; -import { moduleMetadata } from './decorators'; +import { moduleMetadata, applicationConfig } from './decorators'; import { AngularRenderer } from './types'; const defaultContext: Addon_StoryContext = { @@ -31,6 +31,65 @@ class MockService {} @Component({}) class MockComponent {} +describe('applicationConfig', () => { + const provider1 = () => {}; + const provider2 = () => {}; + + it('should apply global config', () => { + expect( + applicationConfig({ + providers: [provider1] as any, + })(() => ({}), defaultContext) + ).toEqual({ + applicationConfig: { + providers: [provider1], + }, + }); + }); + + it('should apply story config', () => { + expect( + applicationConfig({ + providers: [], + })( + () => ({ + applicationConfig: { + providers: [provider2] as any, + }, + }), + { + ...defaultContext, + } + ) + ).toEqual({ + applicationConfig: { + providers: [provider2], + }, + }); + }); + + it('should merge global and story config', () => { + expect( + applicationConfig({ + providers: [provider1] as any, + })( + () => ({ + applicationConfig: { + providers: [provider2] as any, + }, + }), + { + ...defaultContext, + } + ) + ).toEqual({ + applicationConfig: { + providers: [provider1, provider2], + }, + }); + }); +}); + describe('moduleMetadata', () => { it('should add metadata to a story without it', () => { const result = moduleMetadata({ diff --git a/code/frameworks/angular/src/client/decorators.ts b/code/frameworks/angular/src/client/decorators.ts index b34b416e71a2..e1580fe81967 100644 --- a/code/frameworks/angular/src/client/decorators.ts +++ b/code/frameworks/angular/src/client/decorators.ts @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign */ import { Type } from '@angular/core'; +import { ApplicationConfig } from '@angular/platform-browser'; import { DecoratorFunction, StoryContext } from '@storybook/types'; import { computesTemplateFromComponent } from './angular-beta/ComputesTemplateFromComponent'; import { isComponent } from './angular-beta/utils/NgComponentAnalyzer'; @@ -29,6 +30,34 @@ export const moduleMetadata = }; }; +/** + * Decorator to set the config options which are available during the application bootstrap operation + */ +export function applicationConfig( + /** + * Set of config options available during the application bootstrap operation. + */ + config: ApplicationConfig +): DecoratorFunction { + return (storyFn) => { + const story = storyFn(); + + const storyConfig: ApplicationConfig | undefined = story.applicationConfig; + + return { + ...story, + applicationConfig: + storyConfig || config + ? { + ...config, + ...storyConfig, + providers: [...(config?.providers || []), ...(storyConfig?.providers || [])], + } + : undefined, + }; + }; +} + export const componentWrapperDecorator = ( element: Type | ((story: string) => string), diff --git a/code/frameworks/angular/src/client/types.ts b/code/frameworks/angular/src/client/types.ts index 656ce3e43dab..b0e34ec7663d 100644 --- a/code/frameworks/angular/src/client/types.ts +++ b/code/frameworks/angular/src/client/types.ts @@ -1,3 +1,5 @@ +import { Provider, importProvidersFrom } from '@angular/core'; +import { ApplicationConfig } from '@angular/platform-browser'; import { Parameters as DefaultParameters, StoryContext as DefaultStoryContext, @@ -5,12 +7,22 @@ import { } from '@storybook/types'; export interface NgModuleMetadata { + /** + * List of components, directives, and pipes that belong to your component. + */ declarations?: any[]; entryComponents?: any[]; + /** + * List of modules that should be available to the root Storybook Component and all its children. + * If you want to register application providers or if you want to use the forRoot() pattern, please use the `applicationConfig` decorator in combination with the importProvidersFrom helper function from @angular/core instead. + */ imports?: any[]; schemas?: any[]; - providers?: any[]; - singletons?: any[]; + /** + * List of providers that should be available on the root component and all its children. + * Use the `applicationConfig` decorator to register environemt and application-wide providers. + */ + providers?: Provider[]; } export interface ICollection { [p: string]: any; @@ -23,6 +35,7 @@ export interface StoryFnAngularReturnType { /** @deprecated `propsMeta` story input is deprecated, and will be removed in Storybook 7.0. */ propsMeta?: ICollection; moduleMetadata?: NgModuleMetadata; + applicationConfig?: ApplicationConfig; template?: string; styles?: string[]; userDefinedTemplate?: boolean; diff --git a/code/frameworks/angular/template/stories/core/applicationConfig/with-browser-animations.stories.ts b/code/frameworks/angular/template/stories/core/applicationConfig/with-browser-animations.stories.ts new file mode 100644 index 000000000000..2e918cfb9ae1 --- /dev/null +++ b/code/frameworks/angular/template/stories/core/applicationConfig/with-browser-animations.stories.ts @@ -0,0 +1,38 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { within, userEvent } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; +import { importProvidersFrom } from '@angular/core'; +import { OpenCloseComponent } from '../moduleMetadata/angular-src/open-close-component/open-close.component'; + +const meta: Meta = { + component: OpenCloseComponent, + parameters: { + chromatic: { delay: 100 }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const WithBrowserAnimations: Story = { + render: () => ({ + template: ``, + applicationConfig: { + providers: [importProvidersFrom(BrowserAnimationsModule)], + }, + moduleMetadata: { + declarations: [OpenCloseComponent], + }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const opened = canvas.getByText('The box is now Open!'); + expect(opened).toBeDefined(); + const submitButton = canvas.getByRole('button'); + await userEvent.click(submitButton); + const closed = canvas.getByText('The box is now Closed!'); + expect(closed).toBeDefined(); + }, +}; diff --git a/code/frameworks/angular/template/stories/core/applicationConfig/with-noop-browser-animations.stories.ts b/code/frameworks/angular/template/stories/core/applicationConfig/with-noop-browser-animations.stories.ts new file mode 100644 index 000000000000..107a0898dc5e --- /dev/null +++ b/code/frameworks/angular/template/stories/core/applicationConfig/with-noop-browser-animations.stories.ts @@ -0,0 +1,35 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { within, userEvent } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; +import { importProvidersFrom } from '@angular/core'; +import { OpenCloseComponent } from '../moduleMetadata/angular-src/open-close-component/open-close.component'; + +const meta: Meta = { + component: OpenCloseComponent, +}; + +export default meta; + +type Story = StoryObj; + +export const WithNoopBrowserAnimations: Story = { + render: () => ({ + template: ``, + applicationConfig: { + providers: [importProvidersFrom(NoopAnimationsModule)], + }, + moduleMetadata: { + declarations: [OpenCloseComponent], + }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const opened = canvas.getByText('The box is now Open!'); + expect(opened).toBeDefined(); + const submitButton = canvas.getByRole('button'); + await userEvent.click(submitButton); + const closed = canvas.getByText('The box is now Closed!'); + expect(closed).toBeDefined(); + }, +}; diff --git a/code/frameworks/angular/template/stories/core/moduleMetadata/with-browser-animations.stories.ts b/code/frameworks/angular/template/stories/core/moduleMetadata/with-browser-animations.stories.ts deleted file mode 100644 index d2c6e514bd28..000000000000 --- a/code/frameworks/angular/template/stories/core/moduleMetadata/with-browser-animations.stories.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Meta, StoryFn } from '@storybook/angular'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { within, userEvent } from '@storybook/testing-library'; -import { expect } from '@storybook/jest'; -import { OpenCloseComponent } from './angular-src/open-close-component/open-close.component'; - -export default { - component: OpenCloseComponent, - parameters: { - chromatic: { delay: 100 }, - }, -} as Meta; - -export const WithBrowserAnimations: StoryFn = () => ({ - template: ``, - moduleMetadata: { - declarations: [OpenCloseComponent], - imports: [BrowserAnimationsModule], - }, -}); - -WithBrowserAnimations.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const opened = canvas.getByText('The box is now Open!'); - expect(opened).toBeDefined(); - const submitButton = canvas.getByRole('button'); - await userEvent.click(submitButton); - const closed = canvas.getByText('The box is now Closed!'); - expect(closed).toBeDefined(); -}; diff --git a/code/frameworks/angular/template/stories/core/moduleMetadata/with-noop-browser-animations.stories.ts b/code/frameworks/angular/template/stories/core/moduleMetadata/with-noop-browser-animations.stories.ts deleted file mode 100644 index 91623fb2a263..000000000000 --- a/code/frameworks/angular/template/stories/core/moduleMetadata/with-noop-browser-animations.stories.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Meta, StoryFn } from '@storybook/angular'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { within, userEvent } from '@storybook/testing-library'; -import { expect } from '@storybook/jest'; -import { OpenCloseComponent } from './angular-src/open-close-component/open-close.component'; - -export default { - component: OpenCloseComponent, -} as Meta; - -export const WithNoopBrowserAnimations: StoryFn = () => ({ - template: ``, - moduleMetadata: { - declarations: [OpenCloseComponent], - imports: [NoopAnimationsModule], - }, -}); - -WithNoopBrowserAnimations.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const opened = canvas.getByText('The box is now Open!'); - expect(opened).toBeDefined(); - const submitButton = canvas.getByRole('button'); - await userEvent.click(submitButton); - const closed = canvas.getByText('The box is now Closed!'); - expect(closed).toBeDefined(); -}; diff --git a/code/frameworks/angular/template/stories/others/ngx-translate/README.stories.mdx b/code/frameworks/angular/template/stories/others/ngx-translate/README.stories.mdx index 32d1cfcd3b7b..b998907b39cf 100644 --- a/code/frameworks/angular/template/stories/others/ngx-translate/README.stories.mdx +++ b/code/frameworks/angular/template/stories/others/ngx-translate/README.stories.mdx @@ -15,7 +15,7 @@ Here is a simple example with a storybook decorator that you can place in the `p import { HttpClient, HttpClientModule } from '@angular/common/http'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; -import { moduleMetadata } from '@storybook/angular'; +import { moduleMetadata, applicationConfig } from '@storybook/angular'; function createTranslateLoader(http: HttpClient) { return new TranslateHttpLoader(http, '/assets/i18n/', '.json'); @@ -24,23 +24,30 @@ function createTranslateLoader(http: HttpClient) { const TranslateModuleDecorator = (storyFunc, context) => { const { locale } = context.globals; - return moduleMetadata({ - imports: [ - HttpClientModule, - TranslateModule.forRoot({ - defaultLanguage: locale, - loader: { - provide: TranslateLoader, - useFactory: createTranslateLoader, - deps: [HttpClient], - }, - }), - ], + return applicationConfig({ + providers: [ + importProvidersFrom( + HttpClientModule, + TranslateModule.forRoot({ + defaultLanguage: locale, + loader: { + provide: TranslateLoader, + useFactory: createTranslateLoader, + deps: [HttpClient], + }, + }) + ) + ] })(storyFunc, context); }; // for `preview.ts` -export const decorators = [TranslateModuleDecorator]; +export const decorators = [ + moduleMetadata({ + imports: [TranslateModule], + }), + TranslateModuleDecorator, +]; ``` If the `TranslateModule.forRoot` is made by another module you can try to set this provider `DEFAULT_LANGUAGE` @@ -51,7 +58,7 @@ import { DEFAULT_LANGUAGE } from '@ngx-translate/core'; const TranslateModuleDecorator = (storyFunc, context) => { const { locale } = context.globals; - return moduleMetadata({ + return applicationConfig({ providers: [{ provide: DEFAULT_LANGUAGE, useValue: locale }], })(storyFunc, context); };