From a45e827419df7044d23d9adc9e5e80dcf4cebcc0 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Fri, 6 Mar 2026 16:14:47 -0600 Subject: [PATCH 01/91] feat: introduce @storybook/angular-vite package --- code/core/src/common/versions.ts | 1 + code/core/src/types/modules/frameworks.ts | 1 + code/frameworks/angular-vite/README.md | 5 + code/frameworks/angular-vite/build-config.ts | 55 + .../frameworks/angular-vite/build-schema.json | 192 +++ code/frameworks/angular-vite/builders.json | 14 + code/frameworks/angular-vite/package.json | 119 ++ code/frameworks/angular-vite/preset.js | 1 + code/frameworks/angular-vite/project.json | 11 + .../angular-vite/src/__tests__/button.css | 0 .../builders/build-storybook/index.spec.ts | 244 +++ .../src/builders/build-storybook/index.ts | 207 +++ .../src/builders/build-storybook/schema.json | 125 ++ .../builders/start-storybook/index.spec.ts | 233 +++ .../src/builders/start-storybook/index.ts | 253 ++++ .../src/builders/start-storybook/schema.json | 161 ++ .../src/builders/utils/error-handler.ts | 33 + .../src/builders/utils/run-compodoc.spec.ts | 119 ++ .../src/builders/utils/run-compodoc.ts | 45 + .../src/builders/utils/standalone-options.ts | 19 + .../client/angular-beta/AbstractRenderer.ts | 239 +++ .../src/client/angular-beta/CanvasRenderer.ts | 18 + .../ComputesTemplateFromComponent.test.ts | 747 ++++++++++ .../ComputesTemplateFromComponent.ts | 231 +++ .../src/client/angular-beta/DocsRenderer.ts | 52 + .../angular-beta/RendererFactory.test.ts | 273 ++++ .../client/angular-beta/RendererFactory.ts | 60 + .../angular-beta/StorybookModule.test.ts | 370 +++++ .../client/angular-beta/StorybookModule.ts | 39 + .../client/angular-beta/StorybookProvider.ts | 35 + .../angular-beta/StorybookWrapperComponent.ts | 151 ++ .../__testfixtures__/input.component.ts | 52 + .../__testfixtures__/test.module.ts | 8 + .../angular-beta/utils/BootstrapQueue.test.ts | 200 +++ .../angular-beta/utils/BootstrapQueue.ts | 55 + .../utils/NgComponentAnalyzer.test.ts | 381 +++++ .../angular-beta/utils/NgComponentAnalyzer.ts | 135 ++ .../utils/NgModulesAnalyzer.test.ts | 26 + .../angular-beta/utils/NgModulesAnalyzer.ts | 55 + .../utils/PropertyExtractor.test.ts | 201 +++ .../angular-beta/utils/PropertyExtractor.ts | 218 +++ .../src/client/angular-beta/utils/StoryUID.ts | 40 + .../src/client/angular-beta/utils/Zoneless.ts | 9 + .../src/client/argsToTemplate.test.ts | 113 ++ .../angular-vite/src/client/argsToTemplate.ts | 83 ++ .../angular-vite/src/client/compodoc-types.ts | 115 ++ .../angular-vite/src/client/compodoc.test.ts | 136 ++ .../angular-vite/src/client/compodoc.ts | 307 ++++ .../angular-vite/src/client/config.ts | 20 + .../src/client/csf-factories.test.ts | 371 +++++ .../src/client/decorateStory.test.ts | 345 +++++ .../angular-vite/src/client/decorateStory.ts | 71 + .../src/client/decorators.test.ts | 179 +++ .../angular-vite/src/client/decorators.ts | 85 ++ .../doc-button/argtypes.snapshot | 441 ++++++ .../doc-button/compodoc-posix.snapshot | 1326 +++++++++++++++++ .../doc-button/compodoc-undefined.snapshot | 1326 +++++++++++++++++ .../doc-button/compodoc-windows.snapshot | 1297 ++++++++++++++++ .../docs/__testfixtures__/doc-button/input.ts | 199 +++ .../doc-button/properties.snapshot | 230 +++ .../__testfixtures__/doc-button/tsconfig.json | 7 + .../client/docs/angular-properties.test.ts | 39 + .../angular-vite/src/client/docs/config.ts | 15 + .../src/client/docs/sourceDecorator.ts | 62 + .../angular-vite/src/client/globals.ts | 37 + .../angular-vite/src/client/index.ts | 10 + .../src/client/portable-stories.ts | 42 + .../angular-vite/src/client/preview-prod.ts | 3 + .../angular-vite/src/client/preview.ts | 257 ++++ .../angular-vite/src/client/public-types.ts | 90 ++ .../angular-vite/src/client/render.ts | 26 + .../angular-vite/src/client/types.ts | 49 + code/frameworks/angular-vite/src/index.ts | 16 + .../frameworks/angular-vite/src/node/index.ts | 7 + code/frameworks/angular-vite/src/preset.ts | 237 +++ code/frameworks/angular-vite/src/renderer.ts | 6 + .../empty-projects-entry/angular.json | 4 + .../minimal-config/angular.json | 18 + .../minimal-config/src/main.ts | 2 + .../minimal-config/src/tsconfig.app.json | 9 + .../minimal-config/tsconfig.json | 13 + .../some-config/angular.json | 19 + .../some-config/src/main.ts | 2 + .../some-config/src/tsconfig.app.json | 9 + .../some-config/tsconfig.json | 13 + .../with-angularBrowserTarget/angular.json | 64 + .../with-angularBrowserTarget/src/main.ts | 2 + .../with-angularBrowserTarget/src/styles.css | 2 + .../src/tsconfig.app.json | 9 + .../with-angularBrowserTarget/tsconfig.json | 13 + .../with-lib/angular.json | 28 + .../with-lib/projects/pattern-lib/src/main.ts | 2 + .../projects/pattern-lib/tsconfig.lib.json | 20 + .../with-lib/tsconfig.json | 13 + .../with-nx-workspace/nx.json | 3 + .../with-nx-workspace/src/main.ts | 2 + .../with-nx-workspace/src/styles.css | 2 + .../with-nx-workspace/src/styles.scss | 2 + .../with-nx-workspace/src/tsconfig.app.json | 8 + .../with-nx-workspace/tsconfig.json | 14 + .../with-nx-workspace/workspace.json | 18 + .../with-nx/angular.json | 18 + .../__mocks-ng-workspace__/with-nx/nx.json | 3 + .../with-nx/src/main.ts | 2 + .../with-nx/src/styles.css | 2 + .../with-nx/src/styles.scss | 2 + .../with-nx/src/tsconfig.app.json | 8 + .../with-nx/tsconfig.json | 14 + .../with-options-styles/angular.json | 18 + .../with-options-styles/src/main.ts | 2 + .../with-options-styles/src/styles.css | 2 + .../with-options-styles/src/styles.scss | 2 + .../with-options-styles/src/tsconfig.app.json | 9 + .../with-options-styles/tsconfig.json | 13 + .../angular.json | 11 + .../without-architect-build/angular.json | 5 + .../without-compatible-projects/angular.json | 7 + .../without-projects-entry/angular.json | 3 + .../projects/pattern-lib/src/main.ts | 2 + .../projects/pattern-lib/tsconfig.lib.json | 20 + .../without-projects-entry/tsconfig.json | 13 + .../without-tsConfig/angular.json | 16 + .../without-tsConfig/src/main.ts | 2 + .../without-tsConfig/src/tsconfig.app.json | 9 + .../without-tsConfig/tsconfig.json | 13 + .../src/server/__tests__/angular.json | 96 ++ .../angular-vite/src/server/preset-options.ts | 14 + .../frameworks/angular-vite/src/test-setup.ts | 8 + code/frameworks/angular-vite/src/types.ts | 42 + code/frameworks/angular-vite/src/typings.d.ts | 18 + .../frameworks/angular-vite/start-schema.json | 227 +++ .../template/cli/button.component.ts | 48 + .../template/cli/button.stories.ts | 49 + .../template/cli/header.component.ts | 76 + .../template/cli/header.stories.ts | 33 + .../template/cli/page.component.ts | 82 + .../angular-vite/template/cli/page.stories.ts | 32 + .../angular-vite/template/cli/user.ts | 3 + .../template/components/button.component.ts | 47 + .../template/components/button.css | 30 + .../template/components/form.component.ts | 39 + .../template/components/html.component.ts | 25 + .../angular-vite/template/components/index.js | 6 + .../template/components/pre.component.ts | 24 + .../doc-button/doc-button.component.html | 7 + .../doc-button/doc-button.component.scss | 3 + .../doc-button/doc-button.component.ts | 234 +++ .../argTypes/doc-button/doc-button.stories.ts | 31 + .../doc-directive/doc-directive.directive.ts | 22 + .../doc-directive/doc-directive.stories.ts | 20 + .../doc-injectable/doc-injectable.service.ts | 20 + .../doc-injectable/doc-injectable.stories.ts | 20 + .../argTypes/doc-pipe/doc-pipe.pipe.ts | 18 + .../argTypes/doc-pipe/doc-pipe.stories.ts | 20 + .../template/stories/basics/README.mdx | 7 + .../custom-cva-component.stories.ts | 35 + .../custom-cva.component.ts | 57 + .../attribute-selector.component.ts | 26 + .../attribute-selectors.component.stories.ts | 14 + .../class-selector.component.stories.ts | 8 + .../class-selector.component.ts | 26 + ...ltiple-class-selector.component.stories.ts | 8 + .../multiple-selector.component.stories.ts | 8 + .../multiple-selector.component.ts | 48 + .../component-with-enums/enums.component.html | 8 + .../enums.component.stories.ts | 28 + .../component-with-enums/enums.component.ts | 50 + .../base-button.component.ts | 11 + .../base-button.stories.ts | 16 + .../icon-button.component.ts | 12 + .../icon-button.stories.ts | 19 + .../ng-content-about-parent.stories.ts | 60 + .../ng-content-simple.stories.ts | 38 + .../component-with-on-destroy.stories.ts | 45 + .../on-push-box.component.ts | 22 + .../component-with-on-push/on-push.stories.ts | 24 + .../custom-pipes.stories.ts | 37 + .../basics/component-with-pipe/custom.pipe.ts | 12 + .../with-pipe.component.ts | 11 + .../component-with-provider/di.component.html | 7 + .../di.component.stories.ts | 31 + .../component-with-provider/di.component.ts | 33 + .../component-with-style/styled.component.css | 3 + .../styled.component.html | 5 + .../styled.component.scss | 5 + .../styled.component.stories.ts | 16 + .../component-with-style/styled.component.ts | 9 + .../template.component.ts | 27 + .../template.stories.ts | 26 + ...ut-selector-ng-component-outlet.stories.ts | 79 + .../without-selector.component.ts | 31 + .../without-selector.stories.ts | 38 + .../ng-module/angular-src/chip-color.token.ts | 3 + .../ng-module/angular-src/chip-text.pipe.ts | 31 + .../ng-module/angular-src/chip.component.ts | 61 + .../angular-src/chips-group.component.ts | 50 + .../ng-module/angular-src/chips.module.ts | 32 + .../ng-module/import-module-chip.stories.ts | 27 + .../import-module-for-root.stories.ts | 54 + .../basics/ng-module/import-module.stories.ts | 38 + .../template/stories/button.component.ts | 27 + .../template/stories/core/README.mdx | 7 + .../child.component.ts | 21 + .../decorators.stories.ts | 107 ++ .../parent.component.ts | 19 + .../theme-decorator/decorators.stories.ts | 21 + .../moduleMetadata/angular-src/custom.pipe.ts | 12 + .../angular-src/dummy.service.ts | 14 + .../angular-src/service.component.ts | 24 + .../angular-src/token.component.ts | 32 + .../in-export-default.stories.ts | 40 + .../core/moduleMetadata/in-stories.stories.ts | 47 + .../merge-default-and-story.stories.ts | 37 + .../parameters/bootstrap-options.stories.ts | 23 + .../core/styles/story-styles.stories.ts | 52 + .../app-initializer-use-factory.stories.ts | 39 + .../issues/12009-unknown-component.stories.ts | 16 + .../stories/others/ngx-translate/README.mdx | 65 + .../signal/button.component.ts | 41 + .../signal/button.css | 30 + .../signal/button.stories.ts | 66 + .../signal/button.component.ts | 41 + .../signal/button.css | 30 + .../signal/button.stories.ts | 66 + code/frameworks/angular-vite/tsconfig.json | 11 + .../angular-vite/tsconfig.spec.json | 9 + code/frameworks/angular-vite/vitest.config.ts | 14 + code/package.json | 1 + scripts/build/entry-configs.ts | 3 + 229 files changed, 16912 insertions(+) create mode 100644 code/frameworks/angular-vite/README.md create mode 100644 code/frameworks/angular-vite/build-config.ts create mode 100644 code/frameworks/angular-vite/build-schema.json create mode 100644 code/frameworks/angular-vite/builders.json create mode 100644 code/frameworks/angular-vite/package.json create mode 100644 code/frameworks/angular-vite/preset.js create mode 100644 code/frameworks/angular-vite/project.json create mode 100644 code/frameworks/angular-vite/src/__tests__/button.css create mode 100644 code/frameworks/angular-vite/src/builders/build-storybook/index.spec.ts create mode 100644 code/frameworks/angular-vite/src/builders/build-storybook/index.ts create mode 100644 code/frameworks/angular-vite/src/builders/build-storybook/schema.json create mode 100644 code/frameworks/angular-vite/src/builders/start-storybook/index.spec.ts create mode 100644 code/frameworks/angular-vite/src/builders/start-storybook/index.ts create mode 100644 code/frameworks/angular-vite/src/builders/start-storybook/schema.json create mode 100644 code/frameworks/angular-vite/src/builders/utils/error-handler.ts create mode 100644 code/frameworks/angular-vite/src/builders/utils/run-compodoc.spec.ts create mode 100644 code/frameworks/angular-vite/src/builders/utils/run-compodoc.ts create mode 100644 code/frameworks/angular-vite/src/builders/utils/standalone-options.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/AbstractRenderer.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/CanvasRenderer.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/DocsRenderer.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/StorybookProvider.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/StorybookWrapperComponent.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/input.component.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/test.module.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/StoryUID.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/Zoneless.ts create mode 100644 code/frameworks/angular-vite/src/client/argsToTemplate.test.ts create mode 100644 code/frameworks/angular-vite/src/client/argsToTemplate.ts create mode 100644 code/frameworks/angular-vite/src/client/compodoc-types.ts create mode 100644 code/frameworks/angular-vite/src/client/compodoc.test.ts create mode 100644 code/frameworks/angular-vite/src/client/compodoc.ts create mode 100644 code/frameworks/angular-vite/src/client/config.ts create mode 100644 code/frameworks/angular-vite/src/client/csf-factories.test.ts create mode 100644 code/frameworks/angular-vite/src/client/decorateStory.test.ts create mode 100644 code/frameworks/angular-vite/src/client/decorateStory.ts create mode 100644 code/frameworks/angular-vite/src/client/decorators.test.ts create mode 100644 code/frameworks/angular-vite/src/client/decorators.ts create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/argtypes.snapshot create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-posix.snapshot create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-undefined.snapshot create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-windows.snapshot create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/input.ts create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/properties.snapshot create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/client/docs/angular-properties.test.ts create mode 100644 code/frameworks/angular-vite/src/client/docs/config.ts create mode 100644 code/frameworks/angular-vite/src/client/docs/sourceDecorator.ts create mode 100644 code/frameworks/angular-vite/src/client/globals.ts create mode 100644 code/frameworks/angular-vite/src/client/index.ts create mode 100644 code/frameworks/angular-vite/src/client/portable-stories.ts create mode 100644 code/frameworks/angular-vite/src/client/preview-prod.ts create mode 100644 code/frameworks/angular-vite/src/client/preview.ts create mode 100644 code/frameworks/angular-vite/src/client/public-types.ts create mode 100644 code/frameworks/angular-vite/src/client/render.ts create mode 100644 code/frameworks/angular-vite/src/client/types.ts create mode 100644 code/frameworks/angular-vite/src/index.ts create mode 100644 code/frameworks/angular-vite/src/node/index.ts create mode 100644 code/frameworks/angular-vite/src/preset.ts create mode 100644 code/frameworks/angular-vite/src/renderer.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/empty-projects-entry/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/styles.css create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/tsconfig.lib.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/nx.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.css create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.scss create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/workspace.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/nx.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.css create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.scss create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.css create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.scss create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build-options/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-compatible-projects/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/tsconfig.lib.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__tests__/angular.json create mode 100644 code/frameworks/angular-vite/src/server/preset-options.ts create mode 100644 code/frameworks/angular-vite/src/test-setup.ts create mode 100644 code/frameworks/angular-vite/src/types.ts create mode 100644 code/frameworks/angular-vite/src/typings.d.ts create mode 100644 code/frameworks/angular-vite/start-schema.json create mode 100644 code/frameworks/angular-vite/template/cli/button.component.ts create mode 100644 code/frameworks/angular-vite/template/cli/button.stories.ts create mode 100644 code/frameworks/angular-vite/template/cli/header.component.ts create mode 100644 code/frameworks/angular-vite/template/cli/header.stories.ts create mode 100644 code/frameworks/angular-vite/template/cli/page.component.ts create mode 100644 code/frameworks/angular-vite/template/cli/page.stories.ts create mode 100644 code/frameworks/angular-vite/template/cli/user.ts create mode 100644 code/frameworks/angular-vite/template/components/button.component.ts create mode 100644 code/frameworks/angular-vite/template/components/button.css create mode 100644 code/frameworks/angular-vite/template/components/form.component.ts create mode 100644 code/frameworks/angular-vite/template/components/html.component.ts create mode 100644 code/frameworks/angular-vite/template/components/index.js create mode 100644 code/frameworks/angular-vite/template/components/pre.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.html create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.scss create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.directive.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.service.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.pipe.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/README.mdx create mode 100644 code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva-component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selector.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selectors.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-class-selector.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.html create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-about-parent.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-simple.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-ng-on-destroy/component-with-on-destroy.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push-box.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom-pipes.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom.pipe.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-pipe/with-pipe.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.html create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.css create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.html create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.scss create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-template/template.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-template/template.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-without-selector/without-selector-ng-component-outlet.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-without-selector/without-selector.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-without-selector/without-selector.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/angular-src/chip-color.token.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/angular-src/chip-text.pipe.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/angular-src/chip.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/angular-src/chips-group.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/angular-src/chips.module.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/import-module-chip.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/import-module-for-root.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/import-module.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/button.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/README.mdx create mode 100644 code/frameworks/angular-vite/template/stories/core/decorators/componentWrapperDecorator/child.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/decorators/componentWrapperDecorator/decorators.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/decorators/componentWrapperDecorator/parent.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/decorators/theme-decorator/decorators.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/angular-src/custom.pipe.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/angular-src/dummy.service.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/angular-src/service.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/angular-src/token.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/in-export-default.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/in-stories.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/merge-default-and-story.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/parameters/bootstrap-options.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/styles/story-styles.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/others/app-initializer-use-factory/app-initializer-use-factory.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/others/issues/12009-unknown-component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/others/ngx-translate/README.mdx create mode 100644 code/frameworks/angular-vite/template/stories_angular-cli-default-ts/signal/button.component.ts create mode 100644 code/frameworks/angular-vite/template/stories_angular-cli-default-ts/signal/button.css create mode 100644 code/frameworks/angular-vite/template/stories_angular-cli-default-ts/signal/button.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories_angular-cli-prerelease/signal/button.component.ts create mode 100644 code/frameworks/angular-vite/template/stories_angular-cli-prerelease/signal/button.css create mode 100644 code/frameworks/angular-vite/template/stories_angular-cli-prerelease/signal/button.stories.ts create mode 100644 code/frameworks/angular-vite/tsconfig.json create mode 100644 code/frameworks/angular-vite/tsconfig.spec.json create mode 100644 code/frameworks/angular-vite/vitest.config.ts diff --git a/code/core/src/common/versions.ts b/code/core/src/common/versions.ts index 9fb3ef3e06b4..ef4104a5021f 100644 --- a/code/core/src/common/versions.ts +++ b/code/core/src/common/versions.ts @@ -11,6 +11,7 @@ export default { '@storybook/builder-webpack5': '10.4.0-alpha.17', storybook: '10.4.0-alpha.17', '@storybook/angular': '10.4.0-alpha.17', + '@storybook/angular-vite': '10.4.0-alpha.17', '@storybook/ember': '10.4.0-alpha.17', '@storybook/html-vite': '10.4.0-alpha.17', '@storybook/nextjs': '10.4.0-alpha.17', diff --git a/code/core/src/types/modules/frameworks.ts b/code/core/src/types/modules/frameworks.ts index f09694bd5181..98676afe5786 100644 --- a/code/core/src/types/modules/frameworks.ts +++ b/code/core/src/types/modules/frameworks.ts @@ -2,6 +2,7 @@ export enum SupportedFramework { // CORE ANGULAR = 'angular', + ANGULAR_VITE = 'angular-vite', EMBER = 'ember', HTML_VITE = 'html-vite', NEXTJS = 'nextjs', diff --git a/code/frameworks/angular-vite/README.md b/code/frameworks/angular-vite/README.md new file mode 100644 index 000000000000..7bdee1b0c30a --- /dev/null +++ b/code/frameworks/angular-vite/README.md @@ -0,0 +1,5 @@ +# Storybook for Angular + +See [documentation](https://storybook.js.org/docs/get-started/frameworks/angular?renderer=angular&ref=readme) for installation instructions, usage examples, APIs, and more. + +Learn more about Storybook at [storybook.js.org](https://storybook.js.org/?ref=readme). diff --git a/code/frameworks/angular-vite/build-config.ts b/code/frameworks/angular-vite/build-config.ts new file mode 100644 index 000000000000..4dfda837be90 --- /dev/null +++ b/code/frameworks/angular-vite/build-config.ts @@ -0,0 +1,55 @@ +import type { BuildEntries } from '../../../scripts/build/utils/entry-utils'; + +const config: BuildEntries = { + entries: { + browser: [ + { + exportEntries: ['.'], + entryPoint: './src/index.ts', + }, + { + exportEntries: ['./client'], + entryPoint: './src/client/index.ts', + dts: false, + }, + { + exportEntries: ['./client/config'], + entryPoint: './src/client/config.ts', + dts: false, + }, + { + exportEntries: ['./client/preview-prod'], + entryPoint: './src/client/preview-prod.ts', + dts: false, + }, + { + exportEntries: ['./client/docs/config'], + entryPoint: './src/client/docs/config.ts', + dts: false, + }, + ], + node: [ + { + exportEntries: ['./node'], + entryPoint: './src/node/index.ts', + }, + { + exportEntries: ['./preset'], + entryPoint: './src/preset.ts', + dts: false, + }, + { + exportEntries: ['./builders/start-storybook'], + entryPoint: './src/builders/start-storybook/index.ts', + dts: false, + }, + { + exportEntries: ['./builders/build-storybook'], + entryPoint: './src/builders/build-storybook/index.ts', + dts: false, + }, + ], + }, +}; + +export default config; diff --git a/code/frameworks/angular-vite/build-schema.json b/code/frameworks/angular-vite/build-schema.json new file mode 100644 index 000000000000..be414e8b3395 --- /dev/null +++ b/code/frameworks/angular-vite/build-schema.json @@ -0,0 +1,192 @@ +{ + "$schema": "http://json-schema.org/schema", + "title": "Build Storybook", + "description": "Serve up storybook in development mode.", + "type": "object", + "properties": { + "browserTarget": { + "type": "string", + "description": "Build target to be served in project-name:builder:config format. Should generally target on the builder: '@angular-devkit/build-angular:browser'. Useful for Storybook to use options (styles, assets, ...).", + "pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$", + "default": null + }, + "tsConfig": { + "type": "string", + "description": "The full path for the TypeScript configuration file, relative to the current workspace." + }, + "outputDir": { + "type": "string", + "description": "Directory where to store built files.", + "default": "storybook-static" + }, + "preserveSymlinks": { + "type": "boolean", + "description": "Do not use the real path when resolving modules. If true, symlinks are resolved to their real path, if false, symlinks are resolved to their symlinked path.", + "default": false + }, + "configDir": { + "type": "string", + "description": "Directory where to load Storybook configurations from.", + "default": ".storybook" + }, + "loglevel": { + "type": "string", + "description": "Controls level of logging during build. Can be one of: [trace, debug, info (default), warn, error, silent].", + "pattern": "(trace|debug|info|warn|error|silent)" + }, + "logfile": { + "type": "string", + "description": "If provided, the log output will be written to the specified file path." + }, + "enableProdMode": { + "type": "boolean", + "description": "Disable Angular's development mode, which turns off assertions and other checks within the framework.", + "default": true + }, + "quiet": { + "type": "boolean", + "description": "Suppress verbose build output.", + "default": false + }, + "docs": { + "type": "boolean", + "description": "Starts Storybook in documentation mode. Learn more about it : https://storybook.js.org/docs/writing-docs/build-documentation#preview-storybooks-documentation.", + "default": false + }, + "test": { + "type": "boolean", + "description": "Build the static version of the sandbox optimized for testing purposes", + "default": false + }, + "compodoc": { + "type": "boolean", + "description": "Execute compodoc before.", + "default": true + }, + "compodocArgs": { + "type": "array", + "description": "Compodoc options : https://compodoc.app/guides/options.html. Options `-p` with tsconfig path and `-d` with workspace root is always given.", + "default": ["-e", "json"], + "items": { + "type": "string" + } + }, + "statsJson": { + "type": ["boolean", "string"], + "description": "Write stats JSON to disk", + "default": false + }, + "previewUrl": { + "type": "string", + "description": "Disables the default storybook preview and lets you use your own" + }, + "styles": { + "type": "array", + "description": "Global styles to be included in the build.", + "items": { + "$ref": "#/definitions/styleElement" + } + }, + "stylePreprocessorOptions": { + "description": "Options to pass to style preprocessors.", + "type": "object", + "properties": { + "includePaths": { + "description": "Paths to include. Paths will be resolved to workspace root.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "assets": { + "type": "array", + "description": "List of static application assets.", + "default": [], + "items": { + "$ref": "#/definitions/assetPattern" + } + }, + "sourceMap": { + "type": ["boolean", "object"], + "description": "Configure sourcemaps. See: https://angular.io/guide/workspace-config#source-map-configuration", + "default": false + }, + "experimentalZoneless": { + "type": "boolean", + "description": "Experimental: Use zoneless change detection." + } + }, + "additionalProperties": false, + "definitions": { + "assetPattern": { + "oneOf": [ + { + "type": "object", + "properties": { + "followSymlinks": { + "type": "boolean", + "default": false, + "description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched." + }, + "glob": { + "type": "string", + "description": "The pattern to match." + }, + "input": { + "type": "string", + "description": "The input directory path in which to apply 'glob'. Defaults to the project root." + }, + "ignore": { + "description": "An array of globs to ignore.", + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "string", + "description": "Absolute path within the output." + } + }, + "additionalProperties": false, + "required": ["glob", "input", "output"] + }, + { + "type": "string" + } + ] + }, + "styleElement": { + "oneOf": [ + { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The file to include." + }, + "bundleName": { + "type": "string", + "pattern": "^[\\w\\-.]*$", + "description": "The bundle name for this extra entry point." + }, + "inject": { + "type": "boolean", + "description": "If the bundle will be referenced in the HTML file.", + "default": true + } + }, + "additionalProperties": false, + "required": ["input"] + }, + { + "type": "string", + "description": "The file to include." + } + ] + } + } +} diff --git a/code/frameworks/angular-vite/builders.json b/code/frameworks/angular-vite/builders.json new file mode 100644 index 000000000000..650305a2f279 --- /dev/null +++ b/code/frameworks/angular-vite/builders.json @@ -0,0 +1,14 @@ +{ + "builders": { + "build-storybook": { + "implementation": "./dist/builders/build-storybook/index.js", + "schema": "./build-schema.json", + "description": "Build storybook" + }, + "start-storybook": { + "implementation": "./dist/builders/start-storybook/index.js", + "schema": "./start-schema.json", + "description": "Start storybook" + } + } +} diff --git a/code/frameworks/angular-vite/package.json b/code/frameworks/angular-vite/package.json new file mode 100644 index 000000000000..c48e05a23325 --- /dev/null +++ b/code/frameworks/angular-vite/package.json @@ -0,0 +1,119 @@ +{ + "name": "@storybook/angular-vite", + "version": "10.3.0-alpha.14", + "description": "Storybook for Angular: Develop, document, and test UI components in isolation", + "keywords": [ + "storybook", + "storybook-framework", + "angular", + "component", + "components" + ], + "homepage": "https://github.com/storybookjs/storybook/tree/next/code/frameworks/angular-vite", + "bugs": { + "url": "https://github.com/storybookjs/storybook/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/storybookjs/storybook.git", + "directory": "code/frameworks/angular-vite" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "code": "./src/index.ts", + "default": "./dist/index.js" + }, + "./builders/build-storybook": "./dist/builders/build-storybook/index.js", + "./builders/start-storybook": "./dist/builders/start-storybook/index.js", + "./client": "./dist/client/index.js", + "./client/config": "./dist/client/config.js", + "./client/docs/config": "./dist/client/docs/config.js", + "./client/preview-prod": "./dist/client/preview-prod.js", + "./node": { + "types": "./dist/node/index.d.ts", + "code": "./src/node/index.ts", + "default": "./dist/node/index.js" + }, + "./package.json": "./package.json", + "./preset": "./dist/preset.js" + }, + "files": [ + "builders.json", + "build-schema.json", + "start-schema.json", + "dist/**/*", + "template/cli/**/*", + "README.md", + "*.js", + "*.mjs", + "*.d.ts", + "!src/**/*" + ], + "dependencies": { + "@storybook/builder-vite": "workspace:*", + "@storybook/global": "^5.0.0", + "telejson": "8.0.0", + "ts-dedent": "^2.0.0", + "vite": ">=7.0.0" + }, + "devDependencies": { + "@analogjs/vite-plugin-angular": "^1.12.1", + "@angular-devkit/architect": "^0.2100.0", + "@angular-devkit/build-angular": "^21.0.0", + "@angular-devkit/core": "^21.0.0", + "@angular/animations": "^21.0.0", + "@angular/common": "^21.0.0", + "@angular/compiler": "^21.0.0", + "@angular/compiler-cli": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/forms": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "@angular/platform-browser-dynamic": "^21.0.0", + "@types/node": "^22.19.1", + "empathic": "^2.0.0", + "rimraf": "^6.0.1", + "typescript": "^5.9.3", + "zone.js": "^0.15.0" + }, + "peerDependencies": { + "@analogjs/vite-plugin-angular": ">=2.0.0", + "@angular-devkit/architect": ">=0.2100.0 < 0.2200.0", + "@angular-devkit/core": ">=21.0.0 < 22.0.0", + "@angular/animations": ">=21.0.0 < 22.0.0", + "@angular/build": ">=21.0.0 < 22.0.0", + "@angular/cli": ">=21.0.0 < 22.0.0", + "@angular/common": ">=21.0.0 < 22.0.0", + "@angular/compiler": ">=21.0.0 < 22.0.0", + "@angular/compiler-cli": ">=21.0.0 < 22.0.0", + "@angular/core": ">=21.0.0 < 22.0.0", + "@angular/platform-browser": ">=21.0.0 < 22.0.0", + "@angular/platform-browser-dynamic": ">=21.0.0 < 22.0.0", + "rxjs": "^6.5.3 || ^7.4.0", + "storybook": "workspace:^", + "typescript": "^5.9.0", + "vite": ">=7.0.0", + "zone.js": ">=0.16.0" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + }, + "@angular/cli": { + "optional": true + }, + "zone.js": { + "optional": true + } + }, + "publishConfig": { + "access": "public" + }, + "builders": "builders.json" +} diff --git a/code/frameworks/angular-vite/preset.js b/code/frameworks/angular-vite/preset.js new file mode 100644 index 000000000000..4bd63d324002 --- /dev/null +++ b/code/frameworks/angular-vite/preset.js @@ -0,0 +1 @@ +export * from './dist/preset.js'; diff --git a/code/frameworks/angular-vite/project.json b/code/frameworks/angular-vite/project.json new file mode 100644 index 000000000000..7ab6079e7cbb --- /dev/null +++ b/code/frameworks/angular-vite/project.json @@ -0,0 +1,11 @@ +{ + "name": "angular-vite", + "projectType": "library", + "targets": { + "compile": {}, + "check": {} + }, + "tags": [ + "library" + ] +} \ No newline at end of file diff --git a/code/frameworks/angular-vite/src/__tests__/button.css b/code/frameworks/angular-vite/src/__tests__/button.css new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/code/frameworks/angular-vite/src/builders/build-storybook/index.spec.ts b/code/frameworks/angular-vite/src/builders/build-storybook/index.spec.ts new file mode 100644 index 000000000000..0b89291f9233 --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/build-storybook/index.spec.ts @@ -0,0 +1,244 @@ +import { Architect, createBuilder } from '@angular-devkit/architect'; +import { TestingArchitectHost } from '@angular-devkit/architect/testing'; +import { schema } from '@angular-devkit/core'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const buildDevStandaloneMock = vi.fn(); +const buildStaticStandaloneMock = vi.fn(); + +const buildMock = { + buildDevStandalone: buildDevStandaloneMock, + buildStaticStandalone: buildStaticStandaloneMock, + withTelemetry: (name: string, options: any, fn: any) => fn(), +}; + +vi.doMock('storybook/internal/core-server', () => buildMock); +vi.doMock('storybook/internal/common', () => ({ + JsPackageManagerFactory: { + getPackageManager: () => ({ + runPackageCommand: mockRunScript, + }), + }, + getEnvConfig: (options: any) => options, + versions: { + storybook: 'x.x.x', + }, +})); +vi.doMock('empathic/find', () => ({ up: () => './storybook/tsconfig.ts' })); + +const mockRunScript = vi.fn(); + +// Randomly fails on CI. TODO: investigate why +describe.skip('Build Storybook Builder', () => { + let architect: Architect; + let architectHost: TestingArchitectHost; + + beforeEach(async () => { + const registry = new schema.CoreSchemaRegistry(); + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + + architectHost = new TestingArchitectHost(); + architect = new Architect(architectHost, registry); + + architectHost.addBuilder( + '@angular-devkit/build-angular:browser', + createBuilder(() => { + return { success: true }; + }) + ); + architectHost.addTarget( + { project: 'angular-cli', target: 'build-2' }, + '@angular-devkit/build-angular:browser', + { + outputPath: 'dist/angular-cli', + index: 'src/index.html', + main: 'src/main.ts', + polyfills: 'src/polyfills.ts', + tsConfig: 'src/tsconfig.app.json', + assets: ['src/favicon.ico', 'src/assets'], + styles: ['src/styles.css'], + scripts: [], + } + ); + + // This will either take a Node package name, or a path to the directory + // for the package.json file. + await architectHost.addBuilderFromPackage(path.join(__dirname, '../../..')); + }); + + beforeEach(() => { + buildStaticStandaloneMock.mockImplementation((_options: unknown) => Promise.resolve(_options)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should start storybook with angularBrowserTarget', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + browserTarget: 'angular-cli:build-2', + compodoc: false, + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: 'angular-cli:build-2', + angularBuilderContext: expect.any(Object), + configDir: '.storybook', + loglevel: undefined, + quiet: false, + disableTelemetry: undefined, + outputDir: 'storybook-static', + packageJson: expect.any(Object), + mode: 'static', + tsConfig: './storybook/tsconfig.ts', + statsJson: false, + }) + ); + }); + + it('should start storybook with tsConfig', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + tsConfig: 'path/to/tsConfig.json', + compodoc: false, + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: null, + angularBuilderContext: expect.any(Object), + configDir: '.storybook', + loglevel: undefined, + quiet: false, + disableTelemetry: undefined, + outputDir: 'storybook-static', + packageJson: expect.any(Object), + mode: 'static', + tsConfig: 'path/to/tsConfig.json', + statsJson: false, + }) + ); + }); + + it('should build storybook with stats.json', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + tsConfig: 'path/to/tsConfig.json', + compodoc: false, + statsJson: true, + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: null, + angularBuilderContext: expect.any(Object), + configDir: '.storybook', + loglevel: undefined, + quiet: false, + outputDir: 'storybook-static', + packageJson: expect.any(Object), + mode: 'static', + tsConfig: 'path/to/tsConfig.json', + statsJson: true, + }) + ); + }); + + it('should throw error', async () => { + buildStaticStandaloneMock.mockRejectedValue(true); + + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + browserTarget: 'angular-cli:build-2', + compodoc: false, + }); + + try { + await run.result; + + expect(false).toEqual('Throw expected'); + } catch (error) { + expect(error).toEqual( + 'Broken build, fix the error above.\nYou may need to refresh the browser.' + ); + } + }); + + it('should run compodoc', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + browserTarget: 'angular-cli:build-2', + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).toHaveBeenCalledWith( + 'compodoc', + ['-p', './storybook/tsconfig.ts', '-d', '.', '-e', 'json'], + '' + ); + expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: 'angular-cli:build-2', + angularBuilderContext: expect.any(Object), + configDir: '.storybook', + loglevel: undefined, + quiet: false, + outputDir: 'storybook-static', + packageJson: expect.any(Object), + mode: 'static', + tsConfig: './storybook/tsconfig.ts', + statsJson: false, + }) + ); + }); + + it('should start storybook with styles options', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + tsConfig: 'path/to/tsConfig.json', + compodoc: false, + styles: ['style.scss'], + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: null, + angularBuilderContext: expect.any(Object), + angularBuilderOptions: { assets: [], styles: ['style.scss'] }, + configDir: '.storybook', + loglevel: undefined, + quiet: false, + outputDir: 'storybook-static', + packageJson: expect.any(Object), + mode: 'static', + tsConfig: 'path/to/tsConfig.json', + statsJson: false, + }) + ); + }); +}); diff --git a/code/frameworks/angular-vite/src/builders/build-storybook/index.ts b/code/frameworks/angular-vite/src/builders/build-storybook/index.ts new file mode 100644 index 000000000000..da6eeab29331 --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/build-storybook/index.ts @@ -0,0 +1,207 @@ +import { readFileSync } from 'node:fs'; + +import { getEnvConfig, getProjectRoot, versions } from 'storybook/internal/common'; +import { buildStaticStandalone, withTelemetry } from 'storybook/internal/core-server'; +import { addToGlobalContext } from 'storybook/internal/telemetry'; +import type { CLIOptions } from 'storybook/internal/types'; +import { logger, logTracker } from 'storybook/internal/node-logger'; + +import type { + BuilderContext, + BuilderHandlerFn, + BuilderOutput, + Target, + Builder as DevkitBuilder, +} from '@angular-devkit/architect'; +import { createBuilder, targetFromTargetString } from '@angular-devkit/architect'; +import type { + BrowserBuilderOptions, + StylePreprocessorOptions, +} from '@angular-devkit/build-angular'; +import type { + AssetPattern, + SourceMapUnion, + StyleElement, +} from '@angular-devkit/build-angular/src/builders/browser/schema'; +import type { JsonObject } from '@angular-devkit/core'; +import * as find from 'empathic/find'; +import * as pkg from 'empathic/package'; + +import { errorSummary, printErrorDetails } from '../utils/error-handler.ts'; +import { runCompodoc } from '../utils/run-compodoc.ts'; +import type { StandaloneOptions } from '../utils/standalone-options.ts'; +import { VERSION } from '@angular/core'; +import { Channel } from 'storybook/internal/channels'; + +addToGlobalContext('cliVersion', versions.storybook); + +export type StorybookBuilderOptions = JsonObject & { + browserTarget?: string | null; + tsConfig?: string; + test: boolean; + docs: boolean; + compodoc: boolean; + compodocArgs: string[]; + enableProdMode?: boolean; + styles?: StyleElement[]; + stylePreprocessorOptions?: StylePreprocessorOptions; + preserveSymlinks?: boolean; + assets?: AssetPattern[]; + sourceMap?: SourceMapUnion; + experimentalZoneless?: boolean; +} & Pick< + // makes sure the option exists + CLIOptions, + | 'outputDir' + | 'configDir' + | 'loglevel' + | 'quiet' + | 'test' + | 'statsJson' + | 'disableTelemetry' + | 'logfile' + | 'previewUrl' + >; + +export type StorybookBuilderOutput = JsonObject & BuilderOutput & { [key: string]: any }; + +type StandaloneBuildOptions = StandaloneOptions & { outputDir: string }; + +const commandBuilder: BuilderHandlerFn = async ( + options, + context +): Promise => { + // Apply logger configuration from builder options + if (options.loglevel) { + logger.setLogLevel(options.loglevel); + } + if (options.logfile) { + logTracker.enableLogWriting(); + } + + logger.intro('Building Storybook'); + + const { tsConfig } = await setup(options, context); + + const docTSConfig = find.up('tsconfig.doc.json', { + cwd: options.configDir, + last: getProjectRoot(), + }); + + if (options.compodoc) { + await runCompodoc( + { compodocArgs: options.compodocArgs, tsconfig: docTSConfig ?? tsConfig }, + context + ); + } + + getEnvConfig(options, { + staticDir: 'SBCONFIG_STATIC_DIR', + outputDir: 'SBCONFIG_OUTPUT_DIR', + configDir: 'SBCONFIG_CONFIG_DIR', + }); + + const { + browserTarget, + stylePreprocessorOptions, + styles, + configDir, + docs, + loglevel, + test, + outputDir, + quiet, + enableProdMode = true, + statsJson, + disableTelemetry, + assets, + previewUrl, + sourceMap = false, + preserveSymlinks = false, + experimentalZoneless = !!(VERSION.major && Number(VERSION.major) >= 21), + } = options; + + const packageJsonPath = pkg.up({ cwd: __dirname }); + const packageJson = + packageJsonPath != null ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : null; + + const standaloneOptions: StandaloneBuildOptions = { + packageJson, + configDir, + ...(docs ? { docs } : {}), + loglevel, + outputDir, + test, + quiet, + enableProdMode, + disableTelemetry, + angularBrowserTarget: browserTarget, + angularBuilderContext: context, + angularBuilderOptions: { + ...(stylePreprocessorOptions ? { stylePreprocessorOptions } : {}), + ...(styles ? { styles } : {}), + ...(assets ? { assets } : {}), + sourceMap, + preserveSymlinks, + experimentalZoneless, + }, + tsConfig, + statsJson, + previewUrl, + }; + + await runInstance({ ...standaloneOptions, mode: 'static' }); + if (logTracker.shouldWriteLogsToFile) { + const logFile = await logTracker.writeToFile(options.logfile as any); + logger.info(`Debug logs are written to: ${logFile}`); + } + logger.outro('Storybook build completed successfully'); + return { success: true } as BuilderOutput; +}; + +export default createBuilder(commandBuilder) as DevkitBuilder; + +async function setup(options: StorybookBuilderOptions, context: BuilderContext) { + let browserOptions: (JsonObject & BrowserBuilderOptions) | undefined; + let browserTarget: Target | undefined; + + if (options.browserTarget) { + browserTarget = targetFromTargetString(options.browserTarget); + browserOptions = await context.validateOptions( + await context.getTargetOptions(browserTarget), + await context.getBuilderNameForTarget(browserTarget) + ); + } + + return { + tsConfig: + options.tsConfig ?? + find.up('tsconfig.json', { cwd: options.configDir, last: getProjectRoot() }) ?? + browserOptions.tsConfig, + }; +} + +async function runInstance(options: StandaloneBuildOptions) { + try { + await withTelemetry( + 'build', + { + cliOptions: options, + presetOptions: { + ...options, + corePresets: [], + overridePresets: [], + channel: new Channel({}), + }, + printError: printErrorDetails, + }, + async () => { + const result = await buildStaticStandalone(options); + return result; + } + ); + } catch (error) { + const summary = errorSummary(error); + throw new Error(summary); + } +} diff --git a/code/frameworks/angular-vite/src/builders/build-storybook/schema.json b/code/frameworks/angular-vite/src/builders/build-storybook/schema.json new file mode 100644 index 000000000000..5171c50c982d --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/build-storybook/schema.json @@ -0,0 +1,125 @@ +{ + "$schema": "http://json-schema.org/schema", + "title": "Build Storybook", + "description": "Serve up storybook in development mode.", + "type": "object", + "properties": { + "tsConfig": { + "type": "string", + "description": "The full path for the TypeScript configuration file, relative to the current workspace." + }, + "outputDir": { + "type": "string", + "description": "Directory where to store built files.", + "default": "storybook-static" + }, + "configDir": { + "type": "string", + "description": "Directory where to load Storybook configurations from.", + "default": ".storybook" + }, + "loglevel": { + "type": "string", + "description": "Controls level of logging during build. Can be one of: [silly, verbose, info (default), warn, error, silent].", + "pattern": "(silly|verbose|info|warn|silent)" + }, + "enableProdMode": { + "type": "boolean", + "description": "Disable Angular's development mode, which turns off assertions and other checks within the framework.", + "default": true + }, + "quiet": { + "type": "boolean", + "description": "Suppress verbose build output.", + "default": false + }, + "docs": { + "type": "boolean", + "description": "Starts Storybook in documentation mode. Learn more about it : https://storybook.js.org/docs/writing-docs/build-documentation#preview-storybooks-documentation.", + "default": false + }, + "test": { + "type": "boolean", + "description": "Build the static version of the sandbox optimized for testing purposes", + "default": false + }, + "compodoc": { + "type": "boolean", + "description": "Execute compodoc before.", + "default": true + }, + "compodocArgs": { + "type": "array", + "description": "Compodoc options : https://compodoc.app/guides/options.html. Options `-p` with tsconfig path and `-d` with workspace root is always given.", + "default": ["-e", "json"], + "items": { + "type": "string" + } + }, + "statsJson": { + "type": ["boolean", "string"], + "description": "Write stats JSON to disk", + "default": false + }, + "previewUrl": { + "type": "string", + "description": "Disables the default storybook preview and lets you use your own" + }, + "experimentalZoneless": { + "type": "boolean", + "description": "Experimental: Use zoneless change detection.", + "default": false + }, + "styles": { + "type": "array", + "description": "Global styles to be included in the build.", + "default": [], + "items": { + "type": "string" + } + }, + "stylePreprocessorOptions": { + "description": "Options to pass to style preprocessors.", + "type": "object", + "properties": { + "loadPaths": { + "description": "Paths to include.", + "type": "array", + "items": { + "type": "string" + } + }, + "sass": { + "description": "Options to pass to the sass preprocessor.", + "type": "object", + "properties": { + "fatalDeprecations": { + "description": "A set of deprecations to treat as fatal. If a deprecation warning of any provided type is encountered during compilation, the compiler will error instead. If a Version is provided, then all deprecations that were active in that compiler version will be treated as fatal.", + "type": "array", + "items": { + "type": "string" + } + }, + "silenceDeprecations": { + "description": " A set of active deprecations to ignore. If a deprecation warning of any provided type is encountered during compilation, the compiler will ignore it instead.", + "type": "array", + "items": { + "type": "string" + } + }, + "futureDeprecations": { + "description": "A set of future deprecations to opt into early. Future deprecations passed here will be treated as active by the compiler, emitting warnings as necessary.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/code/frameworks/angular-vite/src/builders/start-storybook/index.spec.ts b/code/frameworks/angular-vite/src/builders/start-storybook/index.spec.ts new file mode 100644 index 000000000000..ace692f2758a --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/start-storybook/index.spec.ts @@ -0,0 +1,233 @@ +import { Architect, createBuilder } from '@angular-devkit/architect'; +import { TestingArchitectHost } from '@angular-devkit/architect/testing'; +import { schema } from '@angular-devkit/core'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const buildDevStandaloneMock = vi.fn(); +const buildStaticStandaloneMock = vi.fn(); +const buildMock = { + buildDevStandalone: buildDevStandaloneMock, + buildStaticStandalone: buildStaticStandaloneMock, + withTelemetry: (_: string, __: any, fn: any) => fn(), +}; +vi.doMock('storybook/internal/core-server', () => buildMock); +vi.doMock('empathic/find', () => ({ up: () => './storybook/tsconfig.ts' })); + +const mockRunScript = vi.fn(); + +vi.mock('storybook/internal/common', () => ({ + getEnvConfig: (options: any) => options, + versions: { + storybook: 'x.x.x', + }, + JsPackageManagerFactory: { + getPackageManager: () => ({ + runPackageCommand: mockRunScript, + }), + }, +})); + +// Randomly fails on CI. TODO: investigate why +describe.skip('Start Storybook Builder', () => { + let architect: Architect; + let architectHost: TestingArchitectHost; + + beforeEach(async () => { + const registry = new schema.CoreSchemaRegistry(); + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + + architectHost = new TestingArchitectHost(); + architect = new Architect(architectHost, registry); + + architectHost.addBuilder( + '@angular-devkit/build-angular:browser', + createBuilder(() => { + return { success: true }; + }) + ); + architectHost.addTarget( + { project: 'angular-cli', target: 'build-2' }, + '@angular-devkit/build-angular:browser', + { + outputPath: 'dist/angular-cli', + index: 'src/index.html', + main: 'src/main.ts', + polyfills: 'src/polyfills.ts', + tsConfig: 'src/tsconfig.app.json', + assets: ['src/favicon.ico', 'src/assets'], + styles: ['src/styles.css'], + scripts: [], + } + ); + // This will either take a Node package name, or a path to the directory + // for the package.json file. + await architectHost.addBuilderFromPackage(join(__dirname, '../../..')); + }); + + beforeEach(() => { + buildDevStandaloneMock.mockImplementation((_options: unknown) => Promise.resolve(_options)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should start storybook with angularBrowserTarget', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', { + browserTarget: 'angular-cli:build-2', + port: 4400, + compodoc: false, + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildDevStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: 'angular-cli:build-2', + angularBuilderContext: expect.any(Object), + ci: false, + configDir: '.storybook', + disableTelemetry: undefined, + host: 'localhost', + https: false, + packageJson: expect.any(Object), + port: 4400, + quiet: false, + smokeTest: false, + sslCa: undefined, + sslCert: undefined, + sslKey: undefined, + tsConfig: './storybook/tsconfig.ts', + }) + ); + }); + + it('should start storybook with tsConfig', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', { + tsConfig: 'path/to/tsConfig.json', + port: 4400, + compodoc: false, + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildDevStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: null, + angularBuilderContext: expect.any(Object), + ci: false, + configDir: '.storybook', + disableTelemetry: undefined, + host: 'localhost', + https: false, + packageJson: expect.any(Object), + port: 4400, + quiet: false, + smokeTest: false, + sslCa: undefined, + sslCert: undefined, + sslKey: undefined, + tsConfig: 'path/to/tsConfig.json', + }) + ); + }); + + it('should throw error', async () => { + buildDevStandaloneMock.mockRejectedValue(true); + + const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', { + browserTarget: 'angular-cli:build-2', + port: 4400, + compodoc: false, + }); + + try { + await run.result; + + expect(false).toEqual('Throw expected'); + } catch (error) { + expect(error).toEqual( + 'Broken build, fix the error above.\nYou may need to refresh the browser.' + ); + } + }); + + it('should run compodoc', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', { + browserTarget: 'angular-cli:build-2', + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).toHaveBeenCalledWith( + 'compodoc', + ['-p', './storybook/tsconfig.ts', '-d', '.', '-e', 'json'], + '' + ); + expect(buildDevStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: 'angular-cli:build-2', + angularBuilderContext: expect.any(Object), + ci: false, + disableTelemetry: undefined, + configDir: '.storybook', + host: 'localhost', + https: false, + packageJson: expect.any(Object), + port: 9009, + quiet: false, + smokeTest: false, + sslCa: undefined, + sslCert: undefined, + sslKey: undefined, + tsConfig: './storybook/tsconfig.ts', + }) + ); + }); + + it('should start storybook with styles options', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', { + tsConfig: 'path/to/tsConfig.json', + port: 4400, + compodoc: false, + styles: ['src/styles.css'], + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildDevStandaloneMock).toHaveBeenCalledWith({ + angularBrowserTarget: null, + angularBuilderContext: expect.any(Object), + angularBuilderOptions: { assets: [], styles: ['src/styles.css'] }, + disableTelemetry: undefined, + ci: false, + configDir: '.storybook', + host: 'localhost', + https: false, + port: 4400, + packageJson: expect.any(Object), + quiet: false, + smokeTest: false, + sslCa: undefined, + sslCert: undefined, + sslKey: undefined, + tsConfig: 'path/to/tsConfig.json', + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/builders/start-storybook/index.ts b/code/frameworks/angular-vite/src/builders/start-storybook/index.ts new file mode 100644 index 000000000000..27e38f0b0267 --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/start-storybook/index.ts @@ -0,0 +1,253 @@ +import { readFileSync } from 'node:fs'; + +import { getEnvConfig, getProjectRoot, versions } from 'storybook/internal/common'; +import { buildDevStandalone, withTelemetry } from 'storybook/internal/core-server'; +import { addToGlobalContext } from 'storybook/internal/telemetry'; +import type { CLIOptions } from 'storybook/internal/types'; +import { logger, logTracker } from 'storybook/internal/node-logger'; + +import type { + BuilderContext, + BuilderHandlerFn, + BuilderOutput, + Target, + Builder as DevkitBuilder, +} from '@angular-devkit/architect'; +import { createBuilder, targetFromTargetString } from '@angular-devkit/architect'; +import type { + BrowserBuilderOptions, + StylePreprocessorOptions, +} from '@angular-devkit/build-angular'; +import type { + AssetPattern, + SourceMapUnion, + StyleElement, +} from '@angular-devkit/build-angular/src/builders/browser/schema'; +import type { JsonObject } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import * as find from 'empathic/find'; +import * as pkg from 'empathic/package'; + +import { errorSummary, printErrorDetails } from '../utils/error-handler.ts'; +import { runCompodoc } from '../utils/run-compodoc.ts'; +import type { StandaloneOptions } from '../utils/standalone-options.ts'; +import { VERSION } from '@angular/core'; +import { Channel } from 'storybook/internal/channels'; + +addToGlobalContext('cliVersion', versions.storybook); + +export type StorybookBuilderOptions = JsonObject & { + browserTarget?: string | null; + tsConfig?: string; + compodoc: boolean; + compodocArgs: string[]; + enableProdMode?: boolean; + styles?: StyleElement[]; + stylePreprocessorOptions?: StylePreprocessorOptions; + assets?: AssetPattern[]; + preserveSymlinks?: boolean; + sourceMap?: SourceMapUnion; + experimentalZoneless?: boolean; +} & Pick< + // makes sure the option exists + CLIOptions, + | 'port' + | 'host' + | 'configDir' + | 'https' + | 'sslCa' + | 'sslCert' + | 'sslKey' + | 'smokeTest' + | 'ci' + | 'quiet' + | 'disableTelemetry' + | 'initialPath' + | 'open' + | 'docs' + | 'logfile' + | 'statsJson' + | 'loglevel' + | 'previewUrl' + >; + +export type StorybookBuilderOutput = JsonObject & BuilderOutput & {}; + +const commandBuilder: BuilderHandlerFn = ( + options, + context +): Observable => { + return new Observable((observer) => { + (async () => { + try { + // Apply logger configuration from builder options + if (options.loglevel) { + logger.setLogLevel(options.loglevel); + } + if (options.logfile) { + logTracker.enableLogWriting(); + } + + logger.intro('Starting Storybook'); + + const { tsConfig } = await setup(options, context); + + const docTSConfig = find.up('tsconfig.doc.json', { + cwd: options.configDir, + last: getProjectRoot(), + }); + + if (options.compodoc) { + await runCompodoc( + { + compodocArgs: [...options.compodocArgs, ...(options.quiet ? ['--silent'] : [])], + tsconfig: docTSConfig ?? tsConfig, + }, + context + ); + } + + getEnvConfig(options, { + port: 'SBCONFIG_PORT', + host: 'SBCONFIG_HOSTNAME', + staticDir: 'SBCONFIG_STATIC_DIR', + configDir: 'SBCONFIG_CONFIG_DIR', + ci: 'CI', + }); + + options.port = parseInt(`${options.port}`, 10); + + const { + browserTarget, + stylePreprocessorOptions, + styles, + ci, + configDir, + docs, + host, + https, + port, + quiet, + enableProdMode = false, + smokeTest, + sslCa, + sslCert, + sslKey, + disableTelemetry, + assets, + initialPath, + open, + loglevel, + statsJson, + previewUrl, + sourceMap = false, + preserveSymlinks = false, + experimentalZoneless = !!(VERSION.major && Number(VERSION.major) >= 21), + } = options; + + const packageJsonPath = pkg.up({ cwd: __dirname }); + const packageJson = + packageJsonPath != null ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : null; + + const standaloneOptions: StandaloneOptions = { + packageJson, + ci, + configDir, + ...(docs ? { docs } : {}), + host, + https, + port, + quiet, + enableProdMode, + smokeTest, + sslCa, + sslCert, + sslKey, + disableTelemetry, + angularBrowserTarget: browserTarget, + angularBuilderContext: context, + angularBuilderOptions: { + ...(stylePreprocessorOptions ? { stylePreprocessorOptions } : {}), + ...(styles ? { styles } : {}), + ...(assets ? { assets } : {}), + preserveSymlinks, + sourceMap, + experimentalZoneless, + }, + tsConfig, + initialPath, + open, + statsJson, + loglevel, + previewUrl, + }; + + const startedPort = await runInstance(standaloneOptions); + + // Emit success output - the dev server is now running + observer.next({ success: true, info: { port: startedPort } } as BuilderOutput); + + // Don't call observer.complete() - this keeps the Observable alive + // so the dev server continues running. Architect will keep subscribing + // until the Observable completes, which allows watch mode to work. + } catch (error) { + // Write logs to file on failure when enabled + try { + if (logTracker.shouldWriteLogsToFile) { + try { + const logFile = await logTracker.writeToFile(options.logfile as any); + logger.outro(`Debug logs are written to: ${logFile}`); + } catch {} + } + } catch {} + observer.error(error); + } + })(); + }); +}; + +export default createBuilder(commandBuilder) as DevkitBuilder; + +async function setup(options: StorybookBuilderOptions, context: BuilderContext) { + let browserOptions: (JsonObject & BrowserBuilderOptions) | undefined; + let browserTarget: Target | undefined; + + if (options.browserTarget) { + browserTarget = targetFromTargetString(options.browserTarget); + browserOptions = await context.validateOptions( + await context.getTargetOptions(browserTarget), + await context.getBuilderNameForTarget(browserTarget) + ); + } + + return { + tsConfig: + options.tsConfig ?? + find.up('tsconfig.json', { cwd: options.configDir }) ?? + browserOptions.tsConfig, + }; +} +async function runInstance(options: StandaloneOptions) { + try { + const { port } = await withTelemetry( + 'dev', + { + cliOptions: options, + presetOptions: { + ...options, + corePresets: [], + overridePresets: [], + channel: new Channel({}), + }, + printError: printErrorDetails, + }, + () => { + return buildDevStandalone(options); + } + ); + return port; + } catch (error) { + const summarized = errorSummary(error); + throw new Error(String(summarized)); + } +} diff --git a/code/frameworks/angular-vite/src/builders/start-storybook/schema.json b/code/frameworks/angular-vite/src/builders/start-storybook/schema.json new file mode 100644 index 000000000000..6c831f747856 --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/start-storybook/schema.json @@ -0,0 +1,161 @@ +{ + "$schema": "http://json-schema.org/schema", + "title": "Start Storybook", + "description": "Serve up storybook in development mode.", + "type": "object", + "properties": { + "tsConfig": { + "type": "string", + "description": "The full path for the TypeScript configuration file, relative to the current workspace." + }, + "port": { + "type": "number", + "description": "Port to listen on.", + "default": 9009 + }, + "host": { + "type": "string", + "description": "Host to listen on.", + "default": "localhost" + }, + "configDir": { + "type": "string", + "description": "Directory where to load Storybook configurations from.", + "default": ".storybook" + }, + "https": { + "type": "boolean", + "description": "Serve Storybook over HTTPS. Note: You must provide your own certificate information.", + "default": false + }, + "sslCa": { + "type": "string", + "description": "Provide an SSL certificate authority. (Optional with --https, required if using a self-signed certificate)." + }, + "sslCert": { + "type": "string", + "description": "Provide an SSL certificate. (Required with --https)." + }, + "sslKey": { + "type": "string", + "description": "SSL key to use for serving HTTPS." + }, + "smokeTest": { + "type": "boolean", + "description": "Exit after successful start.", + "default": false + }, + "ci": { + "type": "boolean", + "description": "CI mode (skip interactive prompts, don't open browser).", + "default": false + }, + "open": { + "type": "boolean", + "description": "Whether to open Storybook automatically in the browser.", + "default": true + }, + "quiet": { + "type": "boolean", + "description": "Suppress verbose build output.", + "default": false + }, + "enableProdMode": { + "type": "boolean", + "description": "Disable Angular's development mode, which turns off assertions and other checks within the framework.", + "default": false + }, + "docs": { + "type": "boolean", + "description": "Starts Storybook in documentation mode. Learn more about it : https://storybook.js.org/docs/writing-docs/build-documentation#preview-storybooks-documentation.", + "default": false + }, + "compodoc": { + "type": "boolean", + "description": "Execute compodoc before.", + "default": true + }, + "compodocArgs": { + "type": "array", + "description": "Compodoc options : https://compodoc.app/guides/options.html. Options `-p` with tsconfig path and `-d` with workspace root is always given.", + "default": ["-e", "json"], + "items": { + "type": "string" + } + }, + "initialPath": { + "type": "string", + "description": "URL path to be appended when visiting Storybook for the first time" + }, + "statsJson": { + "type": ["boolean", "string"], + "description": "Write stats JSON to disk", + "default": false + }, + "previewUrl": { + "type": "string", + "description": "Disables the default storybook preview and lets you use your own" + }, + "loglevel": { + "type": "string", + "description": "Controls level of logging during build. Can be one of: [silly, verbose, info (default), warn, error, silent].", + "pattern": "(silly|verbose|info|warn|silent)" + }, + "experimentalZoneless": { + "type": "boolean", + "description": "Experimental: Use zoneless change detection.", + "default": false + }, + "styles": { + "type": "array", + "description": "Global styles to be included in the build.", + "default": [], + "items": { + "type": "string" + } + }, + "stylePreprocessorOptions": { + "description": "Options to pass to style preprocessors.", + "type": "object", + "properties": { + "loadPaths": { + "description": "Paths to include.", + "type": "array", + "items": { + "type": "string" + } + }, + "sass": { + "description": "Options to pass to the sass preprocessor.", + "type": "object", + "properties": { + "fatalDeprecations": { + "description": "A set of deprecations to treat as fatal. If a deprecation warning of any provided type is encountered during compilation, the compiler will error instead. If a Version is provided, then all deprecations that were active in that compiler version will be treated as fatal.", + "type": "array", + "items": { + "type": "string" + } + }, + "silenceDeprecations": { + "description": " A set of active deprecations to ignore. If a deprecation warning of any provided type is encountered during compilation, the compiler will ignore it instead.", + "type": "array", + "items": { + "type": "string" + } + }, + "futureDeprecations": { + "description": "A set of future deprecations to opt into early. Future deprecations passed here will be treated as active by the compiler, emitting warnings as necessary.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/code/frameworks/angular-vite/src/builders/utils/error-handler.ts b/code/frameworks/angular-vite/src/builders/utils/error-handler.ts new file mode 100644 index 000000000000..44b75d4e55da --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/utils/error-handler.ts @@ -0,0 +1,33 @@ +import { logger, instance as npmLog } from 'storybook/internal/node-logger'; + +import { dedent } from 'ts-dedent'; + +export const printErrorDetails = (error: any): void => { + // Duplicate code for Standalone error handling + // Source: https://github.com/storybookjs/storybook/blob/39c7ba09ad84fbd466f9c25d5b92791a5450b9f6/lib/core-server/src/build-dev.ts#L136 + npmLog.heading = ''; + + if (error instanceof Error) { + if ((error as any).error) { + logger.error((error as any).error); + } else if ((error as any).stats && (error as any).stats.compilation.errors) { + (error as any).stats.compilation.errors.forEach((e: any) => logger.log(e)); + } else { + logger.error(error as any); + } + } else if (error.compilation?.errors) { + error.compilation.errors.forEach((e: any) => logger.log(e)); + } +}; + +export const errorSummary = (error: any): string => { + return error.close + ? dedent` + FATAL broken build!, will close the process, + Fix the error below and restart storybook. + ` + : dedent` + Broken build, fix the error above. + You may need to refresh the browser. + `; +}; diff --git a/code/frameworks/angular-vite/src/builders/utils/run-compodoc.spec.ts b/code/frameworks/angular-vite/src/builders/utils/run-compodoc.spec.ts new file mode 100644 index 000000000000..7ca3de67f9d4 --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/utils/run-compodoc.spec.ts @@ -0,0 +1,119 @@ +import type { BuilderContext } from '@angular-devkit/architect'; +// @ts-expect-error (TODO) +import type { LoggerApi } from '@angular-devkit/core/src/logger'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { runCompodoc } from './run-compodoc'; + +const mockRunScript = vi.fn().mockResolvedValue({ stdout: '' }); + +vi.mock('storybook/internal/common', () => ({ + JsPackageManagerFactory: { + getPackageManager: () => ({ + runPackageCommand: mockRunScript, + }), + }, +})); +vi.mock('storybook/internal/node-logger', () => ({ + prompt: { + executeTaskWithSpinner: async (fn: any) => { + await fn(); + }, + }, +})); + +const builderContextLoggerMock: LoggerApi = { + createChild: vi.fn(), + log: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), +}; + +describe('runCompodoc', () => { + afterEach(() => { + mockRunScript.mockClear(); + }); + + const builderContextMock = { + workspaceRoot: 'path/to/project', + logger: builderContextLoggerMock, + } as BuilderContext; + + it('should run compodoc with tsconfig from context', async () => { + await runCompodoc( + { + compodocArgs: [], + tsconfig: 'path/to/tsconfig.json', + }, + builderContextMock + ); + + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', 'path/to/tsconfig.json', '-d', 'path/to/project'], + cwd: 'path/to/project', + }); + }); + + it('should run compodoc with tsconfig from compodocArgs', async () => { + await runCompodoc( + { + compodocArgs: ['-p', 'path/to/tsconfig.stories.json'], + tsconfig: 'path/to/tsconfig.json', + }, + builderContextMock + ); + + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-d', 'path/to/project', '-p', 'path/to/tsconfig.stories.json'], + cwd: 'path/to/project', + }); + }); + + it('should run compodoc with default output folder.', async () => { + await runCompodoc( + { + compodocArgs: [], + tsconfig: 'path/to/tsconfig.json', + }, + builderContextMock + ); + + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', 'path/to/tsconfig.json', '-d', 'path/to/project'], + cwd: 'path/to/project', + }); + }); + + it('should run with custom output folder specified with --output compodocArgs', async () => { + await runCompodoc( + { + compodocArgs: ['--output', 'path/to/customFolder'], + tsconfig: 'path/to/tsconfig.json', + }, + builderContextMock + ); + + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', 'path/to/tsconfig.json', '--output', 'path/to/customFolder'], + cwd: 'path/to/project', + }); + }); + + it('should run with custom output folder specified with -d compodocArgs', async () => { + await runCompodoc( + { + compodocArgs: ['-d', 'path/to/customFolder'], + tsconfig: 'path/to/tsconfig.json', + }, + builderContextMock + ); + + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', 'path/to/tsconfig.json', '-d', 'path/to/customFolder'], + cwd: 'path/to/project', + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/builders/utils/run-compodoc.ts b/code/frameworks/angular-vite/src/builders/utils/run-compodoc.ts new file mode 100644 index 000000000000..e93aa9a40711 --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/utils/run-compodoc.ts @@ -0,0 +1,45 @@ +import { isAbsolute, relative } from 'node:path'; + +import { JsPackageManagerFactory } from 'storybook/internal/common'; + +import type { BuilderContext } from '@angular-devkit/architect'; +import { prompt } from 'storybook/internal/node-logger'; + +const hasTsConfigArg = (args: string[]) => args.indexOf('-p') !== -1; +const hasOutputArg = (args: string[]) => + args.indexOf('-d') !== -1 || args.indexOf('--output') !== -1; + +// relative is necessary to workaround a compodoc issue with +// absolute paths on windows machines +const toRelativePath = (pathToTsConfig: string) => { + return isAbsolute(pathToTsConfig) ? relative('.', pathToTsConfig) : pathToTsConfig; +}; + +export const runCompodoc = async ( + { compodocArgs, tsconfig }: { compodocArgs: string[]; tsconfig: string }, + context: BuilderContext +): Promise => { + const tsConfigPath = toRelativePath(tsconfig); + const finalCompodocArgs = [ + 'compodoc', + ...(hasTsConfigArg(compodocArgs) ? [] : ['-p', tsConfigPath]), + ...(hasOutputArg(compodocArgs) ? [] : ['-d', `${context.workspaceRoot || '.'}`]), + ...compodocArgs, + ]; + + const packageManager = JsPackageManagerFactory.getPackageManager(); + + await prompt.executeTaskWithSpinner( + () => + packageManager.runPackageCommand({ + args: finalCompodocArgs, + cwd: context.workspaceRoot, + }), + { + id: 'compodoc', + intro: 'Generating documentation with Compodoc', + success: 'Compodoc finished successfully', + error: 'Compodoc failed', + } + ); +}; diff --git a/code/frameworks/angular-vite/src/builders/utils/standalone-options.ts b/code/frameworks/angular-vite/src/builders/utils/standalone-options.ts new file mode 100644 index 000000000000..c4ec584ad76f --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/utils/standalone-options.ts @@ -0,0 +1,19 @@ +import type { BuilderContext } from '@angular-devkit/architect'; +import { + BuilderOptions, + CLIOptions, + LoadOptions, +} from 'storybook/internal/types'; + +export type StandaloneOptions = CLIOptions & + LoadOptions & + BuilderOptions & { + mode?: 'static' | 'dev'; + enableProdMode: boolean; + angularBrowserTarget: string | null; + angularBuilderOptions?: Record & { + experimentalZoneless?: boolean; + }; + angularBuilderContext?: BuilderContext | null; + tsConfig?: string; + }; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/AbstractRenderer.ts b/code/frameworks/angular-vite/src/client/angular-beta/AbstractRenderer.ts new file mode 100644 index 000000000000..368dddf11d2e --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/AbstractRenderer.ts @@ -0,0 +1,239 @@ +import type { ApplicationRef, NgModule } from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import type { Subject } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; +import { stringify } from 'telejson'; + +import type { ICollection, StoryFnAngularReturnType } from '../types'; +import { getApplication } from './StorybookModule'; +import { storyPropsProvider } from './StorybookProvider'; +import { queueBootstrapping } from './utils/BootstrapQueue'; +import { PropertyExtractor } from './utils/PropertyExtractor'; +import { getProvideZonelessChangeDetectionFn } from './utils/Zoneless'; + +type StoryRenderInfo = { + storyFnAngular: StoryFnAngularReturnType; + moduleMetadataSnapshot: string; +}; + +declare global { + const STORYBOOK_ANGULAR_OPTIONS: { + experimentalZoneless: boolean; + }; +} + +const applicationRefs = new Map(); + +/** + * Attribute name for the story UID that may be written to the targetDOMNode. + * + * If a target DOM node has a story UID attribute, it will be used as part of the selector for the + * Angular component. + */ +export const STORY_UID_ATTRIBUTE = 'data-sb-story-uid'; + +export abstract class AbstractRenderer { + /** Wait and destroy the platform */ + public static resetApplications(domNode?: HTMLElement) { + applicationRefs.forEach((appRef, appDOMNode) => { + if (!appRef.destroyed && (!domNode || appDOMNode === domNode)) { + appRef.destroy(); + } + }); + } + + protected previousStoryRenderInfo = new Map(); + + // Observable to change the properties dynamically without reloading angular module&component + protected storyProps$: Subject; + + protected abstract beforeFullRender(domNode?: HTMLElement): Promise; + + /** + * Bootstrap main angular module with main component or send only new `props` with storyProps$ + * + * @param storyFnAngular {StoryFnAngularReturnType} + * @param forced {boolean} If : + * + * - True render will only use the StoryFn `props' in storyProps observable that will update sotry's + * component/template properties. Improves performance without reloading the whole + * module&component if props changes + * - False fully recharges or initializes angular module & component + * + * @param component {Component} + */ + public async render({ + storyFnAngular, + forced, + component, + targetDOMNode, + }: { + storyFnAngular: StoryFnAngularReturnType; + forced: boolean; + component?: any; + targetDOMNode: HTMLElement; + }) { + const targetSelector = this.generateTargetSelectorFromStoryId(targetDOMNode.id); + + const newStoryProps$ = new BehaviorSubject(storyFnAngular.props); + + if ( + !this.fullRendererRequired({ + targetDOMNode, + storyFnAngular, + moduleMetadata: { + ...storyFnAngular.moduleMetadata, + }, + forced, + }) + ) { + this.storyProps$.next(storyFnAngular.props); + + return; + } + + await this.beforeFullRender(targetDOMNode); + + // Complete last BehaviorSubject and set a new one for the current module + if (this.storyProps$) { + this.storyProps$.complete(); + } + this.storyProps$ = newStoryProps$; + + this.initAngularRootElement(targetDOMNode, targetSelector); + + const analyzedMetadata = new PropertyExtractor(storyFnAngular.moduleMetadata, component); + await analyzedMetadata.init(); + + const storyUid = this.generateStoryUIdFromRawStoryUid( + targetDOMNode.getAttribute(STORY_UID_ATTRIBUTE) + ); + const componentSelector = storyUid !== null ? `${targetSelector}[${storyUid}]` : targetSelector; + if (storyUid !== null) { + const element = targetDOMNode.querySelector(targetSelector); + element.toggleAttribute(storyUid, true); + } + + const application = getApplication({ + storyFnAngular, + component, + targetSelector: componentSelector, + analyzedMetadata, + }); + + const providers = [ + storyPropsProvider(newStoryProps$), + ...analyzedMetadata.applicationProviders, + ...(storyFnAngular.applicationConfig?.providers ?? []), + ]; + + if (STORYBOOK_ANGULAR_OPTIONS?.experimentalZoneless) { + const provideZonelessChangeDetectionFn = await getProvideZonelessChangeDetectionFn(); + + if (!provideZonelessChangeDetectionFn) { + throw new Error('Zoneless change detection requires Angular 18 or higher'); + } else { + providers.unshift(provideZonelessChangeDetectionFn()); + } + } + + const applicationRef = await queueBootstrapping(() => { + return bootstrapApplication(application, { + ...storyFnAngular.applicationConfig, + providers, + }); + }); + + applicationRefs.set(targetDOMNode, applicationRef); + } + + /** + * Only ASCII alphanumerics can be used as HTML tag name. https://html.spec.whatwg.org/#elements-2 + * + * Therefore, stories break when non-ASCII alphanumerics are included in target selector. + * https://github.com/storybookjs/storybook/issues/15147 + * + * This method returns storyId when it doesn't contain any non-ASCII alphanumerics. Otherwise, it + * generates a valid HTML tag name from storyId by removing non-ASCII alphanumerics from storyId, + * prefixing "sb-", and suffixing "-component" + * + * @memberof AbstractRenderer + * @protected + */ + protected generateTargetSelectorFromStoryId(id: string) { + const invalidHtmlTag = /[^A-Za-z0-9-]/g; + const storyIdIsInvalidHtmlTagName = invalidHtmlTag.test(id); + return storyIdIsInvalidHtmlTagName ? `sb-${id.replace(invalidHtmlTag, '')}-component` : id; + } + + /** + * Angular is unable to handle components that have selectors with accented attributes. + * + * Therefore, stories break when meta's title contains accents. + * https://github.com/storybookjs/storybook/issues/29132 + * + * This method filters accents from a given raw id. For example, this method converts + * 'Example/Button with an "é" accent' into 'Example/Button with an "e" accent'. + * + * @memberof AbstractRenderer + * @protected + */ + protected generateStoryUIdFromRawStoryUid(rawStoryUid: string | null) { + if (rawStoryUid === null) { + return rawStoryUid; + } + + const accentCharacters = /[\u0300-\u036f]/g; + return rawStoryUid.normalize('NFD').replace(accentCharacters, ''); + } + + /** Adds DOM element that angular will use as bootstrap component. */ + protected initAngularRootElement(targetDOMNode: HTMLElement, targetSelector: string) { + targetDOMNode.innerHTML = ''; + targetDOMNode.appendChild(document.createElement(targetSelector)); + } + + private fullRendererRequired({ + targetDOMNode, + storyFnAngular, + moduleMetadata, + forced, + }: { + targetDOMNode: HTMLElement; + storyFnAngular: StoryFnAngularReturnType; + moduleMetadata: NgModule; + forced: boolean; + }) { + const previousStoryRenderInfo = this.previousStoryRenderInfo.get(targetDOMNode); + + const currentStoryRender = { + storyFnAngular, + moduleMetadataSnapshot: stringify(moduleMetadata, { maxDepth: 50 }), + }; + + this.previousStoryRenderInfo.set(targetDOMNode, currentStoryRender); + + 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 && + previousStoryRenderInfo?.storyFnAngular?.template !== storyFnAngular.template; + if (hasChangedTemplate) { + return true; + } + + // force the rendering if the metadata structure has changed + const hasChangedModuleMetadata = + currentStoryRender.moduleMetadataSnapshot !== previousStoryRenderInfo?.moduleMetadataSnapshot; + + return hasChangedModuleMetadata; + } +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/CanvasRenderer.ts b/code/frameworks/angular-vite/src/client/angular-beta/CanvasRenderer.ts new file mode 100644 index 000000000000..6ff2b5c8736a --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/CanvasRenderer.ts @@ -0,0 +1,18 @@ +import type { Parameters, StoryFnAngularReturnType } from '../types'; +import { AbstractRenderer } from './AbstractRenderer'; + +export class CanvasRenderer extends AbstractRenderer { + public async render(options: { + storyFnAngular: StoryFnAngularReturnType; + forced: boolean; + parameters: Parameters; + component: any; + targetDOMNode: HTMLElement; + }) { + await super.render(options); + } + + async beforeFullRender(): Promise { + CanvasRenderer.resetApplications(); + } +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.test.ts new file mode 100644 index 000000000000..758ac2d93d72 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.test.ts @@ -0,0 +1,747 @@ +import { Component } from '@angular/core'; +import type { ArgTypes } from 'storybook/internal/types'; +import { describe, it, expect } from 'vitest'; +import { + computesTemplateFromComponent, + computesTemplateSourceFromComponent, +} from './ComputesTemplateFromComponent'; +import type { ISomeInterface } from './__testfixtures__/input.component'; +import { ButtonAccent, InputComponent } from './__testfixtures__/input.component'; + +describe('angular template decorator', () => { + it('with props should generate tag with properties', () => { + const component = InputComponent; + const props = { + isDisabled: true, + label: 'Hello world', + accent: ButtonAccent.High, + counter: 4, + 'aria-label': 'Hello world', + }; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual( + `` + ); + }); + + it('with props should generate tag with outputs', () => { + const component = InputComponent; + const props = { + isDisabled: true, + label: 'Hello world', + onClick: ($event: any) => {}, + 'dash-out': ($event: any) => {}, + }; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual( + `` + ); + }); + + it('with no props should generate simple tag', () => { + const component = InputComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(''); + }); + + describe('with component without selector', () => { + @Component({ + template: `The content`, + }) + class WithoutSelectorComponent {} + + it('should add component ng-container', async () => { + const component = WithoutSelectorComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute selector', () => { + @Component({ + selector: 'doc-button[foo]', + template: '', + }) + class WithAttributeComponent {} + + it('should add attribute to template', async () => { + const component = WithAttributeComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute and value selector', () => { + @Component({ + selector: 'doc-button[foo="bar"]', + template: '', + }) + class WithAttributeValueComponent {} + + it('should add attribute to template', async () => { + const component = WithAttributeValueComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute only selector', () => { + @Component({ + selector: '[foo]', + template: '', + }) + class WithAttributeOnlyComponent {} + + it('should create a div and add attribute to template', async () => { + const component = WithAttributeOnlyComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(`
`); + }); + }); + + describe('with component with void element and attribute selector', () => { + @Component({ + selector: 'input[foo]', + template: '', + }) + class VoidElementWithAttributeComponent {} + + it('should create without separate closing tag', async () => { + const component = VoidElementWithAttributeComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute and value only selector', () => { + @Component({ + selector: '[foo="bar"]', + template: '', + }) + class WithAttributeOnlyComponent {} + + it('should create a div and add attribute to template', async () => { + const component = WithAttributeOnlyComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(`
`); + }); + }); + + describe('with component with void element, attribute and value only selector', () => { + @Component({ + selector: 'input[foo="bar"]', + template: '', + }) + class VoidElementWithAttributeComponent {} + + it('should create and add attribute to template without separate closing tag', async () => { + const component = VoidElementWithAttributeComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with class selector', () => { + @Component({ + selector: 'doc-button.foo', + template: '', + }) + class WithClassComponent {} + + it('should add class to template', async () => { + const component = WithClassComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with class only selector', () => { + @Component({ + selector: '.foo', + template: '', + }) + class WithClassComponent {} + + it('should create a div and add attribute to template', async () => { + const component = WithClassComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(`
`); + }); + }); + + describe('with component with multiple selectors', () => { + @Component({ + selector: 'doc-button, doc-button2', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors starting with attribute', () => { + @Component({ + selector: 'doc-button[foo], doc-button2', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors starting with attribute and value', () => { + @Component({ + selector: 'doc-button[foo="bar"], doc-button2', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors including 2 attributes and a class', () => { + @Component({ + selector: 'doc-button, button[foo], .button[foo], button[baz]', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors with line breaks', () => { + @Component({ + selector: `doc-button, + doc-button2`, + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors starting with attribute only with line breaks', () => { + @Component({ + selector: `[foo], + doc-button2`, + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(`
`); + }); + }); + + it('with props should generate tag with properties', () => { + const component = InputComponent; + const props = { + isDisabled: true, + label: 'Hello world', + accent: ButtonAccent.High, + counter: 4, + }; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual( + `` + ); + }); + + it('with props should generate tag with outputs', () => { + const component = InputComponent; + const props = { + isDisabled: true, + label: 'Hello world', + onClick: ($event: any) => {}, + }; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual( + `` + ); + }); + + it('should generate correct property for overridden name for Input', () => { + const component = InputComponent; + const props = { + color: '#ffffff', + }; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); +}); + +describe('angular source decorator', () => { + it('with no props should generate simple tag', () => { + const component = InputComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(''); + }); + + describe('with component without selector', () => { + @Component({ + template: `The content`, + }) + class WithoutSelectorComponent {} + + it('should add component ng-container', async () => { + const component = WithoutSelectorComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual( + `` + ); + }); + }); + + describe('with component with attribute selector', () => { + @Component({ + selector: 'doc-button[foo]', + template: '', + }) + class WithAttributeComponent {} + + it('should add attribute to template', async () => { + const component = WithAttributeComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute and value selector', () => { + @Component({ + selector: 'doc-button[foo="bar"]', + template: '', + }) + class WithAttributeValueComponent {} + + it('should add attribute to template', async () => { + const component = WithAttributeValueComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute only selector', () => { + @Component({ + selector: '[foo]', + template: '', + }) + class WithAttributeOnlyComponent {} + + it('should create a div and add attribute to template', async () => { + const component = WithAttributeOnlyComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(`
`); + }); + }); + + describe('with component with void element and attribute selector', () => { + @Component({ + selector: 'input[foo]', + template: '', + }) + class VoidElementWithAttributeComponent {} + + it('should create without separate closing tag', async () => { + const component = VoidElementWithAttributeComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute and value only selector', () => { + @Component({ + selector: '[foo="bar"]', + template: '', + }) + class WithAttributeOnlyComponent {} + + it('should create a div and add attribute to template', async () => { + const component = WithAttributeOnlyComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(`
`); + }); + }); + + describe('with component with void element, attribute and value only selector', () => { + @Component({ + selector: 'input[foo="bar"]', + template: '', + }) + class VoidElementWithAttributeComponent {} + + it('should create and add attribute to template without separate closing tag', async () => { + const component = VoidElementWithAttributeComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with class selector', () => { + @Component({ + selector: 'doc-button.foo', + template: '', + }) + class WithClassComponent {} + + it('should add class to template', async () => { + const component = WithClassComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with class only selector', () => { + @Component({ + selector: '.foo', + template: '', + }) + class WithClassComponent {} + + it('should create a div and add attribute to template', async () => { + const component = WithClassComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(`
`); + }); + }); + + describe('with component with multiple selectors', () => { + @Component({ + selector: 'doc-button, doc-button2', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors starting with attribute', () => { + @Component({ + selector: 'doc-button[foo], doc-button2', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors starting with attribute and value', () => { + @Component({ + selector: 'doc-button[foo="bar"], doc-button2', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors including 2 attributes and a class', () => { + @Component({ + selector: 'doc-button, button[foo], .button[foo], button[baz]', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors with line breaks', () => { + @Component({ + selector: `doc-button, + doc-button2`, + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors starting with attribute only with line breaks', () => { + @Component({ + selector: `[foo], + doc-button2`, + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(`
`); + }); + }); + + describe('no argTypes', () => { + it('should generate tag-only template with no props', () => { + const component = InputComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + it('with props should generate tag with properties', () => { + const component = InputComponent; + const props = { + isDisabled: true, + label: 'Hello world', + accent: ButtonAccent.High, + counter: 4, + 'aria-label': 'Hello world', + }; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual( + `` + ); + }); + + it('with props should generate tag with outputs', () => { + const component = InputComponent; + const props = { + isDisabled: true, + label: 'Hello world', + onClick: ($event: any) => {}, + 'dash-out': ($event: any) => {}, + }; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual( + `` + ); + }); + + it('should generate correct property for overridden name for Input', () => { + const component = InputComponent; + const props = { + color: '#ffffff', + }; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with argTypes (from compodoc)', () => { + it('should handle enum as strongly typed enum', () => { + const component = InputComponent; + const props = { + isDisabled: false, + label: 'Hello world', + accent: ButtonAccent.High, + }; + const argTypes: ArgTypes = { + accent: { + control: { + options: ['Normal', 'High'], + type: 'radio', + }, + defaultValue: undefined, + table: { + category: 'inputs', + }, + type: { + name: 'enum', + required: true, + value: [], + }, + }, + }; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual( + `` + ); + }); + + it('should handle enum without values as string', () => { + const component = InputComponent; + const props = { + isDisabled: false, + label: 'Hello world', + accent: ButtonAccent.High, + }; + const argTypes: ArgTypes = { + accent: { + control: { + options: ['Normal', 'High'], + type: 'radio', + }, + defaultValue: undefined, + table: { + category: 'inputs', + }, + type: { + name: 'object', + required: true, + value: {}, + }, + }, + }; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual( + `` + ); + }); + + it('should handle simple object as stringified', () => { + const component = InputComponent; + + const someDataObject: ISomeInterface = { + one: 'Hello world', + two: true, + three: [ + `a string literal with "double quotes"`, + `a string literal with 'single quotes'`, + 'a single quoted string with "double quotes"', + "a double quoted string with 'single quotes'", + + "a single quoted string with escaped 'single quotes'", + + 'a double quoted string with escaped "double quotes"', + + `a string literal with \'escaped single quotes\'`, + + `a string literal with \"escaped double quotes\"`, + ], + }; + + const props = { + isDisabled: false, + label: 'Hello world', + someDataObject, + }; + + const source = computesTemplateSourceFromComponent(component, props, null); + // Ideally we should stringify the object, but that could cause the story to break because of unescaped values in the JSON object. + // This will have to do for now + expect(source).toEqual( + `` + ); + }); + + it('should handle circular object as stringified', () => { + const component = InputComponent; + + const someDataObject: ISomeInterface = { + one: 'Hello world', + two: true, + three: [ + `a string literal with "double quotes"`, + `a string literal with 'single quotes'`, + 'a single quoted string with "double quotes"', + "a double quoted string with 'single quotes'", + + "a single quoted string with escaped 'single quotes'", + + 'a double quoted string with escaped "double quotes"', + + `a string literal with \'escaped single quotes\'`, + + `a string literal with \"escaped double quotes\"`, + ], + }; + someDataObject.ref = someDataObject; + + const props = { + isDisabled: false, + label: 'Hello world', + someDataObject, + }; + + const source = computesTemplateSourceFromComponent(component, props, null); + // Ideally we should stringify the object, but that could cause the story to break because of unescaped values in the JSON object. + // This will have to do for now + expect(source).toEqual( + `` + ); + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.ts b/code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.ts new file mode 100644 index 000000000000..523bb6c338e5 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.ts @@ -0,0 +1,231 @@ +import type { ArgTypes } from 'storybook/internal/types'; + +import type { Type } from '@angular/core'; + +import type { ICollection } from '../types'; +import type { ComponentInputsOutputs } from './utils/NgComponentAnalyzer'; +import { + getComponentDecoratorMetadata, + getComponentInputsOutputs, +} from './utils/NgComponentAnalyzer'; + +/** + * Check if the name matches the criteria for a valid identifier. A valid identifier can only + * contain letters, digits, underscores, or dollar signs. It cannot start with a digit. + */ +const isValidIdentifier = (name: string): boolean => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name); + +/** + * Returns the property name, if it can be accessed with dot notation. If not, it returns + * `this['propertyName']`. + */ +export const formatPropInTemplate = (propertyName: string) => + isValidIdentifier(propertyName) ? propertyName : `this['${propertyName}']`; + +const separateInputsOutputsAttributes = ( + ngComponentInputsOutputs: ComponentInputsOutputs, + props: ICollection = {} +) => { + const inputs = ngComponentInputsOutputs.inputs + .filter((i) => i.templateName in props) + .map((i) => i.templateName); + const outputs = ngComponentInputsOutputs.outputs + .filter((o) => o.templateName in props) + .map((o) => o.templateName); + + return { + inputs, + outputs, + otherProps: Object.keys(props).filter((k) => ![...inputs, ...outputs].includes(k)), + }; +}; + +/** + * Converts a component into a template with inputs/outputs present in initial props + * + * @param component + * @param initialProps + * @param innerTemplate + */ +export const computesTemplateFromComponent = ( + component: Type, + initialProps?: ICollection, + innerTemplate = '' +) => { + const ngComponentMetadata = getComponentDecoratorMetadata(component); + const ngComponentInputsOutputs = getComponentInputsOutputs(component); + + if (!ngComponentMetadata.selector) { + // Allow to add renderer component when NgComponent selector is undefined + return ``; + } + + const { inputs: initialInputs, outputs: initialOutputs } = separateInputsOutputsAttributes( + ngComponentInputsOutputs, + initialProps + ); + + const templateInputs = + initialInputs.length > 0 + ? ` ${initialInputs.map((i) => `[${i}]="${formatPropInTemplate(i)}"`).join(' ')}` + : ''; + const templateOutputs = + initialOutputs.length > 0 + ? ` ${initialOutputs.map((i) => `(${i})="${formatPropInTemplate(i)}($event)"`).join(' ')}` + : ''; + + return buildTemplate( + ngComponentMetadata.selector, + innerTemplate, + templateInputs, + templateOutputs + ); +}; + +/** Stringify an object with a placholder in the circular references. */ +function stringifyCircular(obj: any) { + const seen = new Set(); + return JSON.stringify(obj, (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + } + return value; + }); +} + +const createAngularInputProperty = ({ + propertyName, + value, + argType, +}: { + propertyName: string; + value: any; + argType?: ArgTypes[string]; +}) => { + let templateValue; + switch (typeof value) { + case 'string': + templateValue = `'${value}'`; + break; + case 'object': + templateValue = stringifyCircular(value) + .replace(/'/g, '\u2019') + .replace(/\\"/g, '\u201D') + .replace(/"([^-"]+)":/g, '$1: ') + .replace(/"/g, "'") + .replace(/\u2019/g, "\\'") + .replace(/\u201D/g, "\\'") + .split(',') + .join(', '); + break; + default: + templateValue = value; + } + + return `[${propertyName}]="${templateValue}"`; +}; + +/** + * Converts a component into a template with inputs/outputs present in initial props + * + * @param component + * @param initialProps + * @param innerTemplate + */ +export const computesTemplateSourceFromComponent = ( + component: Type, + initialProps?: ICollection, + argTypes?: ArgTypes +) => { + const ngComponentMetadata = getComponentDecoratorMetadata(component); + if (!ngComponentMetadata) { + return null; + } + + if (!ngComponentMetadata.selector) { + // Allow to add renderer component when NgComponent selector is undefined + return ``; + } + + const ngComponentInputsOutputs = getComponentInputsOutputs(component); + const { inputs: initialInputs, outputs: initialOutputs } = separateInputsOutputsAttributes( + ngComponentInputsOutputs, + initialProps + ); + + const templateInputs = + initialInputs.length > 0 + ? ` ${initialInputs + .map((propertyName) => + createAngularInputProperty({ + propertyName, + value: initialProps[propertyName], + argType: argTypes?.[propertyName], + }) + ) + .join(' ')}` + : ''; + const templateOutputs = + initialOutputs.length > 0 + ? ` ${initialOutputs.map((i) => `(${i})="${formatPropInTemplate(i)}($event)"`).join(' ')}` + : ''; + + return buildTemplate(ngComponentMetadata.selector, '', templateInputs, templateOutputs); +}; + +const buildTemplate = ( + selector: string, + innerTemplate: string, + inputs: string, + outputs: string +) => { + // https://www.w3.org/TR/2011/WD-html-markup-20110113/syntax.html#syntax-elements + const voidElements = [ + 'area', + 'base', + 'br', + 'col', + 'command', + 'embed', + 'hr', + 'img', + 'input', + 'keygen', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', + ]; + + const firstSelector = selector.split(',')[0]; + const templateReplacers: [ + string | RegExp, + string | ((substring: string, ...args: any[]) => string), + ][] = [ + [/(^.*?)(?=[,])/, '$1'], + [/(^\..+)/, 'div$1'], + [/(^\[.+?])/, 'div$1'], + [/([\w[\]]+)(\s*,[\w\s-[\],]+)+/, `$1`], + [/#([\w-]+)/, ` id="$1"`], + [/((\.[\w-]+)+)/, (_, c) => ` class="${c.split`.`.join` `.trim()}"`], + [/(\[.+?])/g, (_, a) => ` ${a.slice(1, -1)}`], + [ + /([\S]+)(.*)/, + (template, elementSelector) => { + return voidElements.some((element) => elementSelector === element) + ? template.replace(/([\S]+)(.*)/, `<$1$2${inputs}${outputs} />`) + : template.replace(/([\S]+)(.*)/, `<$1$2${inputs}${outputs}>${innerTemplate}`); + }, + ], + ]; + + return templateReplacers.reduce( + (prevSelector, [searchValue, replacer]) => prevSelector.replace(searchValue, replacer as any), + firstSelector + ); +}; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/DocsRenderer.ts b/code/frameworks/angular-vite/src/client/angular-beta/DocsRenderer.ts new file mode 100644 index 000000000000..84bfdb9f2f26 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/DocsRenderer.ts @@ -0,0 +1,52 @@ +import { DOCS_RENDERED, STORY_CHANGED } from 'storybook/internal/core-events'; +import { addons } from 'storybook/preview-api'; + +import type { Parameters, StoryFnAngularReturnType } from '../types'; +import { AbstractRenderer, STORY_UID_ATTRIBUTE } from './AbstractRenderer'; +import { getNextStoryUID } from './utils/StoryUID'; + +export class DocsRenderer extends AbstractRenderer { + public async render(options: { + storyFnAngular: StoryFnAngularReturnType; + forced: boolean; + component: any; + parameters: Parameters; + targetDOMNode: HTMLElement; + }) { + const channel = addons.getChannel(); + /** + * Destroy and recreate the PlatformBrowserDynamic of angular For several stories to be rendered + * in the same docs we should not destroy angular between each rendering but do it when the + * rendered stories are not needed anymore. + * + * Note for improvement: currently there is one event per story rendered in the doc. But one + * event could be enough for the whole docs + */ + channel.once(STORY_CHANGED, async () => { + await DocsRenderer.resetApplications(); + }); + + /** + * Destroy and recreate the PlatformBrowserDynamic of angular when doc re render. Allows to call + * ngOnDestroy of angular for previous component + */ + channel.once(DOCS_RENDERED, async () => { + await DocsRenderer.resetApplications(); + }); + + await super.render({ ...options, forced: false }); + } + + async beforeFullRender(domNode?: HTMLElement): Promise { + DocsRenderer.resetApplications(domNode); + } + + protected override initAngularRootElement( + targetDOMNode: HTMLElement, + targetSelector: string + ): void { + super.initAngularRootElement(targetDOMNode, targetSelector); + + targetDOMNode.setAttribute(STORY_UID_ATTRIBUTE, getNextStoryUID(targetDOMNode.id)); + } +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.test.ts new file mode 100644 index 000000000000..f5550730b3e4 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.test.ts @@ -0,0 +1,273 @@ +// @vitest-environment happy-dom + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Component, ɵresetJitOptions } from '@angular/core'; +import { platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { CanvasRenderer } from './CanvasRenderer'; +import { RendererFactory } from './RendererFactory'; +import { DocsRenderer } from './DocsRenderer'; + +vi.mock('@angular/platform-browser-dynamic'); + +declare const document: Document; +describe('RendererFactory', () => { + let rendererFactory: RendererFactory; + let rootTargetDOMNode: HTMLElement; + let rootDocstargetDOMNode: HTMLElement; + + beforeEach(async () => { + rendererFactory = new RendererFactory(); + document.body.innerHTML = + '
' + + '
'; + rootTargetDOMNode = global.document.getElementById('storybook-root'); + rootDocstargetDOMNode = global.document.getElementById('root-docs'); + (platformBrowserDynamic as any).mockImplementation(platformBrowserDynamicTesting); + vi.spyOn(console, 'log').mockImplementation(() => {}); + // @ts-expect-error Ignore + globalThis.STORYBOOK_ANGULAR_OPTIONS = { experimentalZoneless: false }; + }); + + afterEach(() => { + vi.clearAllMocks(); + + // Necessary to avoid this error "Provided value for `preserveWhitespaces` can not be changed once it has been set." : + // Source: https://github.com/angular/angular/commit/e342ffd855ffeb8af7067b42307ffa320d82177e#diff-92b125e532cc22977b46a91f068d6d7ea81fd61b772842a4a0212f1cfd875be6R28 + ɵresetJitOptions(); + }); + + describe('CanvasRenderer', () => { + it('should get CanvasRenderer instance', async () => { + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + expect(render).toBeInstanceOf(CanvasRenderer); + }); + + it('should render my-story for story template', async () => { + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + await render?.render({ + storyFnAngular: { + template: '🦊', + props: {}, + }, + forced: false, + targetDOMNode: rootTargetDOMNode, + }); + + expect(document.body.getElementsByTagName('storybook-root')[0].innerHTML).toBe('🦊'); + }); + + it('should render my-story for story component', async () => { + @Component({ selector: 'foo', template: '🦊' }) + class FooComponent {} + + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + await render?.render({ + storyFnAngular: { + props: {}, + }, + forced: false, + component: FooComponent, + targetDOMNode: rootTargetDOMNode, + }); + + expect(document.body.getElementsByTagName('storybook-root')[0].innerHTML).toBe( + '🦊' + ); + }); + + it('should handle circular reference in moduleMetadata', async () => { + class Thing { + token: Thing; + + constructor() { + this.token = this; + } + } + const token = new Thing(); + + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + + await render?.render({ + storyFnAngular: { + template: '🦊', + props: {}, + moduleMetadata: { providers: [{ provide: 'foo', useValue: token }] }, + }, + forced: false, + targetDOMNode: rootTargetDOMNode, + }); + + expect(document.body.getElementsByTagName('storybook-root')[0].innerHTML).toBe('🦊'); + }); + + describe('when forced=true', () => { + beforeEach(async () => { + // Init first render + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + await render?.render({ + storyFnAngular: { + template: '{{ logo }}: {{ name }}', + props: { + logo: '🦊', + name: 'Fox', + }, + }, + forced: true, + targetDOMNode: rootTargetDOMNode, + }); + }); + + it('should be rendered a first time', async () => { + expect(document.body.getElementsByTagName('storybook-root')[0].innerHTML).toBe('🦊: Fox'); + }); + + it('should not be re-rendered when only props change', async () => { + // only props change + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + await render?.render({ + storyFnAngular: { + props: { + logo: '👾', + }, + }, + forced: true, + targetDOMNode: rootTargetDOMNode, + }); + + expect(document.body.getElementsByTagName('storybook-root')[0].innerHTML).toBe('👾: Fox'); + }); + + it('should be re-rendered when template change', async () => { + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + await render?.render({ + storyFnAngular: { + template: '{{ beer }}', + props: { + beer: '🍺', + }, + }, + forced: true, + targetDOMNode: rootTargetDOMNode, + }); + + expect(document.body.getElementsByTagName('storybook-root')[0].innerHTML).toBe('🍺'); + }); + }); + }); + + describe('DocsRenderer', () => { + describe('when canvas render is done before', () => { + beforeEach(async () => { + // Init first Canvas render + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + await render?.render({ + storyFnAngular: { + template: 'Canvas 🖼', + }, + forced: true, + targetDOMNode: rootTargetDOMNode, + }); + }); + + it('should reset root HTML', async () => { + global.document + .getElementById('storybook-root') + .appendChild(global.document.createElement('👾')); + + expect(global.document.getElementById('storybook-root').innerHTML).toContain('Canvas 🖼'); + await rendererFactory.getRendererInstance(rootDocstargetDOMNode); + expect(global.document.getElementById('storybook-root').innerHTML).toBe(''); + }); + }); + + it('should get DocsRenderer instance', async () => { + const render = await rendererFactory.getRendererInstance(rootDocstargetDOMNode); + expect(render).toBeInstanceOf(DocsRenderer); + }); + + describe('when multiple story for the same component', () => { + it('should render both stories', async () => { + @Component({ selector: 'foo', template: '🦊' }) + class FooComponent {} + + const render = await rendererFactory.getRendererInstance( + global.document.getElementById('storybook-docs') + ); + + const targetDOMNode1 = global.document.createElement('div'); + targetDOMNode1.id = 'story-1'; + global.document.getElementById('storybook-docs').appendChild(targetDOMNode1); + await render?.render({ + storyFnAngular: { + props: {}, + }, + forced: false, + component: FooComponent, + targetDOMNode: targetDOMNode1, + }); + + const targetDOMNode2 = global.document.createElement('div'); + targetDOMNode2.id = 'story-1'; + global.document.getElementById('storybook-docs').appendChild(targetDOMNode2); + await render?.render({ + storyFnAngular: { + props: {}, + }, + forced: false, + component: FooComponent, + targetDOMNode: targetDOMNode2, + }); + + expect(global.document.querySelectorAll('#story-1 > story-1')[0].innerHTML).toBe( + '🦊' + ); + expect(global.document.querySelectorAll('#story-1 > story-1')[1].innerHTML).toBe( + '🦊' + ); + }); + }); + + describe('when bootstrapping multiple stories in parallel', () => { + it('should render both stories', async () => { + @Component({ selector: 'foo', template: '🦊' }) + class FooComponent {} + + const render = await rendererFactory.getRendererInstance( + global.document.getElementById('storybook-docs') + ); + + const targetDOMNode1 = global.document.createElement('div'); + targetDOMNode1.id = 'story-1'; + global.document.getElementById('storybook-docs').appendChild(targetDOMNode1); + + const targetDOMNode2 = global.document.createElement('div'); + targetDOMNode2.id = 'story-2'; + global.document.getElementById('storybook-docs').appendChild(targetDOMNode2); + + await Promise.all([ + render.render({ + storyFnAngular: {}, + forced: false, + component: FooComponent, + targetDOMNode: targetDOMNode1, + }), + render.render({ + storyFnAngular: {}, + forced: false, + component: FooComponent, + targetDOMNode: targetDOMNode2, + }), + ]); + + expect(global.document.querySelector('#story-1 > story-1').innerHTML).toBe( + '🦊' + ); + expect(global.document.querySelector('#story-2 > story-2').innerHTML).toBe( + '🦊' + ); + }); + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.ts b/code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.ts new file mode 100644 index 000000000000..afe1ce91000b --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.ts @@ -0,0 +1,60 @@ +import { AbstractRenderer } from './AbstractRenderer'; +import { CanvasRenderer } from './CanvasRenderer'; +import { DocsRenderer } from './DocsRenderer'; + +type RenderType = 'canvas' | 'docs'; +export class RendererFactory { + private lastRenderType: RenderType; + + private rendererMap = new Map(); + + public async getRendererInstance(targetDOMNode: HTMLElement): Promise { + const targetId = targetDOMNode.id; + // do nothing if the target node is null + // fix a problem when the docs asks 2 times the same component at the same time + // the 1st targetDOMNode of the 1st requested rendering becomes null 🤷‍♂️ + if (targetDOMNode === null) { + return null; + } + + const renderType = getRenderType(targetDOMNode); + // keep only instances of the same type + if (this.lastRenderType && this.lastRenderType !== renderType) { + await AbstractRenderer.resetApplications(); + clearRootHTMLElement(renderType); + this.rendererMap.clear(); + } + + if (!this.rendererMap.has(targetId)) { + this.rendererMap.set(targetId, this.buildRenderer(renderType)); + } + + this.lastRenderType = renderType; + return this.rendererMap.get(targetId); + } + + private buildRenderer(renderType: RenderType) { + if (renderType === 'docs') { + return new DocsRenderer(); + } + return new CanvasRenderer(); + } +} + +export const getRenderType = (targetDOMNode: HTMLElement): RenderType => { + return targetDOMNode.id === 'storybook-root' ? 'canvas' : 'docs'; +}; + +export function clearRootHTMLElement(renderType: RenderType) { + switch (renderType) { + case 'canvas': + global.document.getElementById('storybook-docs').innerHTML = ''; + break; + + case 'docs': + global.document.getElementById('storybook-root').innerHTML = ''; + break; + default: + break; + } +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.test.ts new file mode 100644 index 000000000000..6411cdcb81a2 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.test.ts @@ -0,0 +1,370 @@ +// @vitest-environment happy-dom + +import type { NgModule } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { describe, expect, it } from 'vitest'; + +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject } from 'rxjs'; +import type { ICollection } from '../types'; +import { getApplication } from './StorybookModule'; +import { storyPropsProvider } from './StorybookProvider'; +import { PropertyExtractor } from './utils/PropertyExtractor'; + +describe('StorybookModule', () => { + describe('getStorybookModuleMetadata', () => { + describe('with simple component', () => { + @Component({ + selector: 'foo', + template: ` +

{{ input }}

+

{{ localPropertyName }}

+

{{ setterCallNb }}

+

{{ localProperty }}

+

{{ localFunction() }}

+

+

+ `, + }) + class FooComponent { + @Input() + public input: string; + + @Input('inputBindingPropertyName') + public localPropertyName: string; + + @Input() + public set setter(value: string) { + this.setterCallNb += 1; + } + + @Output() + public output = new EventEmitter(); + + @Output('outputBindingPropertyName') + public localOutput = new EventEmitter(); + + public localProperty: string; + + public localFunction = () => ''; + + public setterCallNb = 0; + } + + it('should initialize inputs', async () => { + const props = { + input: 'input', + inputBindingPropertyName: 'inputBindingPropertyName', + localProperty: 'localProperty', + localFunction: () => 'localFunction', + }; + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { props }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(new BehaviorSubject(props))], + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(props.input); + expect(fixture.nativeElement.querySelector('p#inputBindingPropertyName').innerHTML).toEqual( + props.inputBindingPropertyName + ); + expect(fixture.nativeElement.querySelector('p#localProperty').innerHTML).toEqual( + props.localProperty + ); + expect(fixture.nativeElement.querySelector('p#localFunction').innerHTML).toEqual( + props.localFunction() + ); + }); + + it('should initialize outputs', async () => { + let expectedOutputValue: string; + let expectedOutputBindingValue: string; + const props = { + output: (value: string) => { + expectedOutputValue = value; + }, + outputBindingPropertyName: (value: string) => { + expectedOutputBindingValue = value; + }, + }; + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { props }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(new BehaviorSubject(props))], + }); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('p#output').click(); + fixture.nativeElement.querySelector('p#outputBindingPropertyName').click(); + + expect(expectedOutputValue).toEqual('outputEmitted'); + expect(expectedOutputBindingValue).toEqual('outputEmitted'); + }); + + it('should change inputs if storyProps$ Subject emit', async () => { + const initialProps = { + input: 'input', + inputBindingPropertyName: '', + }; + const storyProps$ = new BehaviorSubject(initialProps); + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { props: initialProps }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(storyProps$)], + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual( + initialProps.input + ); + expect(fixture.nativeElement.querySelector('p#inputBindingPropertyName').innerHTML).toEqual( + '' + ); + + const newProps = { + input: 'new input', + inputBindingPropertyName: 'new inputBindingPropertyName', + localProperty: 'new localProperty', + localFunction: () => 'new localFunction', + }; + storyProps$.next(newProps); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(newProps.input); + expect(fixture.nativeElement.querySelector('p#inputBindingPropertyName').innerHTML).toEqual( + newProps.inputBindingPropertyName + ); + expect(fixture.nativeElement.querySelector('p#localProperty').innerHTML).toEqual( + newProps.localProperty + ); + expect(fixture.nativeElement.querySelector('p#localFunction').innerHTML).toEqual( + newProps.localFunction() + ); + }); + + it('should override outputs if storyProps$ Subject emit', async () => { + let expectedOutputValue; + let expectedOutputBindingValue; + const initialProps = { + input: '', + output: (value: string) => { + expectedOutputValue = value; + }, + outputBindingPropertyName: (value: string) => { + expectedOutputBindingValue = value; + }, + }; + const storyProps$ = new BehaviorSubject(initialProps); + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { props: initialProps }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(storyProps$)], + }); + fixture.detectChanges(); + + const newProps = { + input: 'new input', + output: () => { + expectedOutputValue = 'should be called'; + }, + outputBindingPropertyName: () => { + expectedOutputBindingValue = 'should be called'; + }, + }; + storyProps$.next(newProps); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('p#output').click(); + fixture.nativeElement.querySelector('p#outputBindingPropertyName').click(); + + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(newProps.input); + expect(expectedOutputValue).toEqual('should be called'); + expect(expectedOutputBindingValue).toEqual('should be called'); + }); + + it('should change template inputs if storyProps$ Subject emit', async () => { + const initialProps = { + color: 'red', + input: 'input', + }; + const storyProps$ = new BehaviorSubject(initialProps); + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { + props: initialProps, + template: '

', + }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(storyProps$)], + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('p').style.color).toEqual('red'); + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual( + initialProps.input + ); + + const newProps = { + color: 'black', + input: 'new input', + }; + storyProps$.next(newProps); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p').style.color).toEqual('black'); + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(newProps.input); + }); + + it('should call the Input() setter the right number of times', async () => { + const initialProps = { + setter: 'init', + }; + const storyProps$ = new BehaviorSubject(initialProps); + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { props: initialProps }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(storyProps$)], + }); + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#setterCallNb').innerHTML).toEqual('1'); + + const newProps = { + setter: 'new setter value', + }; + storyProps$.next(newProps); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#setterCallNb').innerHTML).toEqual('2'); + }); + }); + + describe('with component without selector', () => { + @Component({ + template: `The content`, + }) + class WithoutSelectorComponent {} + + it('should display the component', async () => { + const props = {}; + + const analyzedMetadata = new PropertyExtractor( + { entryComponents: [WithoutSelectorComponent] }, + WithoutSelectorComponent + ); + + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { + props, + moduleMetadata: { entryComponents: [WithoutSelectorComponent] }, + }, + component: WithoutSelectorComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(new BehaviorSubject(props))], + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.innerHTML).toContain('The content'); + }); + }); + + it('should keep template with an empty value', async () => { + @Component({ + selector: 'foo', + template: `Should not be displayed`, + }) + class FooComponent {} + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { template: '' }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(new BehaviorSubject({}))], + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.innerHTML).toEqual(''); + }); + }); + + async function configureTestingModule(ngModule: NgModule) { + await TestBed.configureTestingModule(ngModule).compileComponents(); + + const fixture = TestBed.createComponent(ngModule.imports[0] as any); + + return { + fixture, + }; + } +}); diff --git a/code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.ts b/code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.ts new file mode 100644 index 000000000000..e2834517f39d --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.ts @@ -0,0 +1,39 @@ +import type { StoryFnAngularReturnType } from '../types'; +import { computesTemplateFromComponent } from './ComputesTemplateFromComponent'; +import { createStorybookWrapperComponent } from './StorybookWrapperComponent'; +import type { 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; + + const hasTemplate = !hasNoTemplate(template); + if (!hasTemplate && component) { + template = computesTemplateFromComponent(component, props, ''); + } + + /** Create a component that wraps generated template and gives it props */ + return createStorybookWrapperComponent({ + moduleMetadata, + selector: targetSelector, + template, + storyComponent: component, + styles, + initialProps: props, + analyzedMetadata, + }); +}; + +function hasNoTemplate(template: string | null | undefined): template is undefined { + return template === null || template === undefined; +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/StorybookProvider.ts b/code/frameworks/angular-vite/src/client/angular-beta/StorybookProvider.ts new file mode 100644 index 000000000000..846496eb10c3 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/StorybookProvider.ts @@ -0,0 +1,35 @@ +import type { Provider } from '@angular/core'; +import { InjectionToken, NgZone } from '@angular/core'; +import type { Subject, Subscriber } from 'rxjs'; +import { Observable } from 'rxjs'; + +import type { ICollection } from '../types'; + +export const STORY_PROPS = new InjectionToken>('STORY_PROPS'); + +export const storyPropsProvider = (storyProps$: Subject): Provider => ({ + provide: STORY_PROPS, + useFactory: storyDataFactory(storyProps$.asObservable()), + deps: [NgZone], +}); + +function storyDataFactory(data: Observable) { + return (ngZone: NgZone) => + new Observable((subscriber: Subscriber) => { + const sub = data.subscribe( + (v: T) => { + ngZone.run(() => subscriber.next(v)); + }, + (err) => { + ngZone.run(() => subscriber.error(err)); + }, + () => { + ngZone.run(() => subscriber.complete()); + } + ); + + return () => { + sub.unsubscribe(); + }; + }); +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/StorybookWrapperComponent.ts b/code/frameworks/angular-vite/src/client/angular-beta/StorybookWrapperComponent.ts new file mode 100644 index 000000000000..afbd08c13376 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/StorybookWrapperComponent.ts @@ -0,0 +1,151 @@ +import type { AfterViewInit, ElementRef, OnDestroy, Type } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + Inject, + NgModule, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import type { Subject, Subscription } from 'rxjs'; +import { map, skip } from 'rxjs/operators'; + +import type { ICollection, NgModuleMetadata } from '../types'; +import { STORY_PROPS } from './StorybookProvider'; +import type { ComponentInputsOutputs } from './utils/NgComponentAnalyzer'; +import { getComponentInputsOutputs } from './utils/NgComponentAnalyzer'; +import { PropertyExtractor } from './utils/PropertyExtractor'; + +const getNonInputsOutputsProps = ( + ngComponentInputsOutputs: ComponentInputsOutputs, + props: ICollection = {} +) => { + const inputs = ngComponentInputsOutputs.inputs + .filter((i) => i.templateName in props) + .map((i) => i.templateName); + const outputs = ngComponentInputsOutputs.outputs + .filter((o) => o.templateName in props) + .map((o) => o.templateName); + return Object.keys(props).filter((k) => ![...inputs, ...outputs].includes(k)); +}; + +/** Wraps the story template into a component */ +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 { imports, declarations, providers } = analyzedMetadata; + + @NgModule({ + declarations, + imports, + exports: [...declarations, ...imports], + }) + class StorybookComponentModule {} + + PropertyExtractor.warnImportsModuleWithProviders(analyzedMetadata); + + @Component({ + selector, + template, + standalone: true, + imports: [StorybookComponentModule], + providers, + styles, + schemas: moduleMetadata.schemas, + }) + class StorybookWrapperComponent implements AfterViewInit, OnDestroy { + private storyComponentPropsSubscription: Subscription; + + private storyWrapperPropsSubscription: Subscription; + + @ViewChild(viewChildSelector, { static: true }) storyComponentElementRef: ElementRef; + + @ViewChild(viewChildSelector, { read: ViewContainerRef, static: true }) + storyComponentViewContainerRef: ViewContainerRef; + + // Used in case of a component without selector + storyComponent = storyComponent ?? ''; + + constructor( + @Inject(STORY_PROPS) private storyProps$: Subject, + @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef + ) {} + + ngOnInit(): void { + // Subscribes to the observable storyProps$ to keep these properties up to date + this.storyWrapperPropsSubscription = this.storyProps$.subscribe((storyProps = {}) => { + // All props are added as component properties + Object.assign(this, storyProps); + + this.changeDetectorRef.detectChanges(); + this.changeDetectorRef.markForCheck(); + }); + } + + ngAfterViewInit(): void { + // Bind properties to component, if the story have component + if (this.storyComponentElementRef) { + const ngComponentInputsOutputs = getComponentInputsOutputs(storyComponent); + + const initialOtherProps = getNonInputsOutputsProps(ngComponentInputsOutputs, initialProps); + + // Initializes properties that are not Inputs | Outputs + // Allows story props to override local component properties + initialOtherProps.forEach((p) => { + (this.storyComponentElementRef as any)[p] = initialProps[p]; + }); + // `markForCheck` the component in case this uses changeDetection: OnPush + // And then forces the `detectChanges` + this.storyComponentViewContainerRef.injector.get(ChangeDetectorRef).markForCheck(); + this.changeDetectorRef.detectChanges(); + + // Once target component has been initialized, the storyProps$ observable keeps target component properties than are not Input|Output up to date + this.storyComponentPropsSubscription = this.storyProps$ + .pipe( + skip(1), + map((props) => { + const propsKeyToKeep = getNonInputsOutputsProps(ngComponentInputsOutputs, props); + return propsKeyToKeep.reduce((acc, p) => ({ ...acc, [p]: props[p] }), {}); + }) + ) + .subscribe((props) => { + // Replace inputs with new ones from props + Object.assign(this.storyComponentElementRef, props); + + // `markForCheck` the component in case this uses changeDetection: OnPush + // And then forces the `detectChanges` + this.storyComponentViewContainerRef.injector.get(ChangeDetectorRef).markForCheck(); + this.changeDetectorRef.detectChanges(); + }); + } + } + + ngOnDestroy(): void { + if (this.storyComponentPropsSubscription != null) { + this.storyComponentPropsSubscription.unsubscribe(); + } + if (this.storyWrapperPropsSubscription != null) { + this.storyWrapperPropsSubscription.unsubscribe(); + } + } + } + return StorybookWrapperComponent; +}; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/input.component.ts b/code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/input.component.ts new file mode 100644 index 000000000000..b0ae93c94b99 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/input.component.ts @@ -0,0 +1,52 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; + ref?: ISomeInterface; +} + +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + @Input() + public counter: number; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** To test source-generation with overridden propertyname */ + @Input('color') public foregroundColor: string; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + @Input() + public label: string; + + @Input('aria-label') public ariaLabel: string; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + @Output() + public onClick = new EventEmitter(); + + @Output('dash-out') public dashOut = new EventEmitter(); +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/test.module.ts b/code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/test.module.ts new file mode 100644 index 000000000000..36536f3873e1 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/test.module.ts @@ -0,0 +1,8 @@ +import { CommonModule } from '@angular/common'; +import { HttpClientModule } from '@angular/common/http'; +import { NgModule } from '@angular/core'; + +@NgModule({ + imports: [CommonModule, HttpClientModule], +}) +export class WithOfficialModule {} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.test.ts new file mode 100644 index 000000000000..f941fd58d1ce --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.test.ts @@ -0,0 +1,200 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Subject, lastValueFrom } from 'rxjs'; + +import { queueBootstrapping } from './BootstrapQueue'; + +const instantWaitFor = (fn: () => void) => { + return vi.waitFor(fn, { + interval: 0, + timeout: 10000, + }); +}; + +describe('BootstrapQueue', { retry: 3 }, () => { + beforeEach(async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('@flaky should wait until complete', async () => { + const pendingSubject = new Subject(); + const bootstrapApp = vi.fn().mockImplementation(async () => { + return lastValueFrom(pendingSubject); + }); + const bootstrapAppFinished = vi.fn(); + + queueBootstrapping(bootstrapApp).then(() => { + bootstrapAppFinished(); + }); + + await instantWaitFor(() => { + if (bootstrapApp.mock.calls.length !== 1) { + throw new Error('bootstrapApp should not have been called yet'); + } + }); + + expect(bootstrapApp).toHaveBeenCalled(); + expect(bootstrapAppFinished).not.toHaveBeenCalled(); + + pendingSubject.next(); + pendingSubject.complete(); + + await instantWaitFor(() => { + if (bootstrapAppFinished.mock.calls.length !== 1) { + throw new Error('bootstrapApp should have been called once'); + } + }); + + expect(bootstrapAppFinished).toHaveBeenCalled(); + }); + + it('should prevent following tasks, until the preview tasks are complete', async () => { + const pendingSubject = new Subject(); + const bootstrapApp = vi.fn().mockImplementation(async () => { + return lastValueFrom(pendingSubject); + }); + const bootstrapAppFinished = vi.fn(); + + const pendingSubject2 = new Subject(); + const bootstrapApp2 = vi.fn().mockImplementation(async () => { + return lastValueFrom(pendingSubject2); + }); + const bootstrapAppFinished2 = vi.fn(); + + const pendingSubject3 = new Subject(); + const bootstrapApp3 = vi.fn().mockImplementation(async () => { + return lastValueFrom(pendingSubject3); + }); + const bootstrapAppFinished3 = vi.fn(); + + queueBootstrapping(bootstrapApp).then(bootstrapAppFinished); + queueBootstrapping(bootstrapApp2).then(bootstrapAppFinished2); + queueBootstrapping(bootstrapApp3).then(bootstrapAppFinished3); + + await instantWaitFor(() => { + if (bootstrapApp.mock.calls.length !== 1) { + throw new Error('bootstrapApp should have been called once'); + } + }); + + expect(bootstrapApp).toHaveBeenCalled(); + expect(bootstrapAppFinished).not.toHaveBeenCalled(); + expect(bootstrapApp2).not.toHaveBeenCalled(); + expect(bootstrapAppFinished2).not.toHaveBeenCalled(); + expect(bootstrapApp3).not.toHaveBeenCalled(); + expect(bootstrapAppFinished3).not.toHaveBeenCalled(); + + pendingSubject.next(); + pendingSubject.complete(); + + await instantWaitFor(() => { + if (bootstrapApp2.mock.calls.length !== 1) { + throw new Error('bootstrapApp2 should have been called once'); + } + }); + + expect(bootstrapApp).toHaveReturnedTimes(1); + expect(bootstrapAppFinished).toHaveBeenCalled(); + expect(bootstrapApp2).toHaveBeenCalled(); + expect(bootstrapAppFinished2).not.toHaveBeenCalled(); + expect(bootstrapApp3).not.toHaveBeenCalled(); + expect(bootstrapAppFinished3).not.toHaveBeenCalled(); + + pendingSubject2.next(); + pendingSubject2.complete(); + + await instantWaitFor(() => { + if (bootstrapApp3.mock.calls.length !== 1) { + throw new Error('bootstrapApp3 should have been called once'); + } + }); + + expect(bootstrapApp).toHaveReturnedTimes(1); + expect(bootstrapAppFinished).toHaveBeenCalled(); + expect(bootstrapApp2).toHaveReturnedTimes(1); + expect(bootstrapAppFinished2).toHaveBeenCalled(); + expect(bootstrapApp3).toHaveBeenCalled(); + expect(bootstrapAppFinished3).not.toHaveBeenCalled(); + + pendingSubject3.next(); + pendingSubject3.complete(); + + await instantWaitFor(() => { + if (bootstrapAppFinished3.mock.calls.length !== 1) { + throw new Error('bootstrapAppFinished3 should have been called once'); + } + }); + + expect(bootstrapApp).toHaveReturnedTimes(1); + expect(bootstrapAppFinished).toHaveBeenCalled(); + expect(bootstrapApp2).toHaveReturnedTimes(1); + expect(bootstrapAppFinished2).toHaveBeenCalled(); + expect(bootstrapApp3).toHaveReturnedTimes(1); + expect(bootstrapAppFinished3).toHaveBeenCalled(); + }); + + it('should throw and continue next bootstrap on error', async () => { + const pendingSubject = new Subject(); + const bootstrapApp = vi.fn().mockImplementation(async () => { + return lastValueFrom(pendingSubject); + }); + const bootstrapAppFinished = vi.fn(); + const bootstrapAppError = vi.fn(); + + const pendingSubject2 = new Subject(); + const bootstrapApp2 = vi.fn().mockImplementation(async () => { + return lastValueFrom(pendingSubject2); + }); + const bootstrapAppFinished2 = vi.fn(); + const bootstrapAppError2 = vi.fn(); + + queueBootstrapping(bootstrapApp).then(bootstrapAppFinished).catch(bootstrapAppError); + queueBootstrapping(bootstrapApp2).then(bootstrapAppFinished2).catch(bootstrapAppError2); + + await instantWaitFor(() => { + if (bootstrapApp.mock.calls.length !== 1) { + throw new Error('bootstrapApp should have been called once'); + } + }); + + expect(bootstrapApp).toHaveBeenCalledTimes(1); + expect(bootstrapAppFinished).not.toHaveBeenCalled(); + expect(bootstrapApp2).not.toHaveBeenCalled(); + + pendingSubject.error(new Error('test error')); + + await instantWaitFor(() => { + if (bootstrapAppError.mock.calls.length !== 1) { + throw new Error('bootstrapAppError should have been called once'); + } + }); + + expect(bootstrapApp).toHaveBeenCalledTimes(1); + expect(bootstrapAppFinished).not.toHaveBeenCalled(); + expect(bootstrapAppError).toHaveBeenCalledTimes(1); + expect(bootstrapApp2).toHaveBeenCalledTimes(1); + expect(bootstrapAppFinished2).not.toHaveBeenCalled(); + expect(bootstrapAppError2).not.toHaveBeenCalled(); + + pendingSubject2.next(); + pendingSubject2.complete(); + + await instantWaitFor(() => { + if (bootstrapAppFinished2.mock.calls.length !== 1) { + throw new Error('bootstrapAppFinished2 should have been called once'); + } + }); + + expect(bootstrapApp).toHaveBeenCalledTimes(1); + expect(bootstrapAppFinished).not.toHaveBeenCalled(); + expect(bootstrapAppError).toHaveBeenCalledTimes(1); + expect(bootstrapApp2).toHaveBeenCalledTimes(1); + expect(bootstrapAppFinished2).toHaveBeenCalledTimes(1); + expect(bootstrapAppError2).not.toHaveBeenCalled(); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.ts new file mode 100644 index 000000000000..73a96e9cc088 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.ts @@ -0,0 +1,55 @@ +import type { ApplicationRef } from '@angular/core'; + +const queue: Array<() => Promise> = []; +let isProcessing = false; + +/** + * Reset compiled components because we often want to compile the same component with more than one + * NgModule. + */ +const resetCompiledComponents = async () => { + try { + // Clear global Angular component cache in order to be able to re-render the same component across multiple stories + // + // Reference: + // https://github.com/angular/angular/blob/2ebe2bcb2fe19bf672316b05f15241fd7fd40803/packages/core/src/render3/jit/module.ts#L377-L384 + const { ɵresetCompiledComponents } = await import('@angular/core'); + ɵresetCompiledComponents(); + } catch (e) { + /** Noop catch This means angular removed or modified ɵresetCompiledComponents */ + } +}; + +/** + * Queue bootstrapping, so that only one application can be bootstrapped at a time. + * + * Bootstrapping multiple applications at once can cause Angular to throw an error that a component + * is declared in multiple modules. This avoids two stories confusing the Angular compiler, by + * bootstrapping more that one application at a time. + * + * @param fn Callback that should complete the bootstrap process + * @returns ApplicationRef from the completed bootstrap process + */ +export const queueBootstrapping = (fn: () => Promise): Promise => { + return new Promise((resolve, reject) => { + queue.push(() => fn().then(resolve).catch(reject)); + + if (!isProcessing) { + processQueue(); + } + }); +}; + +const processQueue = async () => { + isProcessing = true; + + while (queue.length > 0) { + const bootstrappingFn = queue.shift(); + if (bootstrappingFn) { + await bootstrappingFn(); + await resetCompiledComponents(); + } + } + + isProcessing = false; +}; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts new file mode 100644 index 000000000000..4548c1bb243e --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts @@ -0,0 +1,381 @@ +// @vitest-environment happy-dom + +import type { Type } from '@angular/core'; +import { + Component, + ComponentFactoryResolver, + Directive, + EventEmitter, + HostBinding, + Injectable, + Input, + Output, + Pipe, + input, + output, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; +import { describe, expect, it } from 'vitest'; + +import { + getComponentInputsOutputs, + isComponent, + isDeclarable, + getComponentDecoratorMetadata, + isStandaloneComponent, +} from './NgComponentAnalyzer'; + +describe('getComponentInputsOutputs', () => { + it('should return empty if no I/O found', () => { + @Component({ + standalone: false, + }) + class FooComponent {} + + expect(getComponentInputsOutputs(FooComponent)).toEqual({ + inputs: [], + outputs: [], + }); + + class BarComponent {} + + expect(getComponentInputsOutputs(BarComponent)).toEqual({ + inputs: [], + outputs: [], + }); + }); + + it('should return I/O', () => { + @Component({ + template: '', + inputs: ['inputInComponentMetadata'], + outputs: ['outputInComponentMetadata'], + standalone: false, + }) + class FooComponent { + @Input() + public input: string; + + public signalInput = input(); + + public signalInputAliased = input('signalInputAliased', { + alias: 'signalInputAliasedAlias', + }); + + @Input('inputPropertyName') + public inputWithBindingPropertyName: string; + + @Output() + public output = new EventEmitter(); + + @Output('outputPropertyName') + public outputWithBindingPropertyName = new EventEmitter(); + + public signalOutput = output(); + } + + const fooComponentFactory = resolveComponentFactory(FooComponent); + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect({ inputs, outputs }).toEqual({ + inputs: [ + { propName: 'inputInComponentMetadata', templateName: 'inputInComponentMetadata' }, + { propName: 'input', templateName: 'input' }, + { propName: 'inputWithBindingPropertyName', templateName: 'inputPropertyName' }, + ], + outputs: [ + { propName: 'outputInComponentMetadata', templateName: 'outputInComponentMetadata' }, + { propName: 'output', templateName: 'output' }, + { propName: 'outputWithBindingPropertyName', templateName: 'outputPropertyName' }, + ], + }); + + expect(sortByPropName(inputs)).toEqual( + sortByPropName(fooComponentFactory.inputs.map(({ isSignal, ...rest }) => rest)) + ); + expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs)); + }); + + it("should return I/O when some of component metadata has the same name as one of component's properties", () => { + @Component({ + template: '', + inputs: ['input', 'inputWithBindingPropertyName'], + outputs: ['outputWithBindingPropertyName'], + standalone: false, + }) + class FooComponent { + @Input() + public input: string; + + @Input('inputPropertyName') + public inputWithBindingPropertyName: string; + + @Output() + public output = new EventEmitter(); + + @Output('outputPropertyName') + public outputWithBindingPropertyName = new EventEmitter(); + } + + const fooComponentFactory = resolveComponentFactory(FooComponent); + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect(sortByPropName(inputs)).toEqual( + sortByPropName(fooComponentFactory.inputs.map(({ isSignal, ...rest }) => rest)) + ); + expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs)); + }); + + it('should return I/O in the presence of multiple decorators', () => { + @Component({ + template: '', + standalone: false, + }) + class FooComponent { + @Input() + @HostBinding('class.preceeding-first') + public inputPreceedingHostBinding: string; + + @HostBinding('class.following-binding') + @Input() + public inputFollowingHostBinding: string; + } + + const fooComponentFactory = resolveComponentFactory(FooComponent); + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect({ inputs, outputs }).toEqual({ + inputs: [ + { propName: 'inputPreceedingHostBinding', templateName: 'inputPreceedingHostBinding' }, + { propName: 'inputFollowingHostBinding', templateName: 'inputFollowingHostBinding' }, + ], + outputs: [], + }); + + expect(sortByPropName(inputs)).toEqual( + sortByPropName(fooComponentFactory.inputs.map(({ isSignal, ...rest }) => rest)) + ); + expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs)); + }); + + it('should return I/O with extending classes', () => { + @Component({ + template: '', + standalone: false, + }) + class BarComponent { + @Input() + public a: string; + + @Input() + public b: string; + } + + @Component({ + template: '', + standalone: false, + }) + class FooComponent extends BarComponent { + @Input() + declare public b: string; + + @Input() + public c: string; + } + + const fooComponentFactory = resolveComponentFactory(FooComponent); + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect({ inputs, outputs }).toEqual({ + inputs: [ + { propName: 'a', templateName: 'a' }, + { propName: 'b', templateName: 'b' }, + { propName: 'c', templateName: 'c' }, + ], + outputs: [], + }); + + expect(sortByPropName(inputs)).toEqual( + sortByPropName(fooComponentFactory.inputs.map(({ isSignal, ...rest }) => rest)) + ); + expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs)); + }); +}); + +describe('isDeclarable', () => { + it('should return true with a Component', () => { + @Component({}) + class FooComponent {} + + expect(isDeclarable(FooComponent)).toEqual(true); + }); + + it('should return true with a Directive', () => { + @Directive({}) + class FooDirective {} + + expect(isDeclarable(FooDirective)).toEqual(true); + }); + + it('should return true with a Pipe', () => { + @Pipe({ name: 'pipe' }) + class FooPipe {} + + expect(isDeclarable(FooPipe)).toEqual(true); + }); + + it('should return false with simple class', () => { + class FooPipe {} + + expect(isDeclarable(FooPipe)).toEqual(false); + }); + it('should return false with Injectable', () => { + @Injectable() + class FooInjectable {} + + expect(isDeclarable(FooInjectable)).toEqual(false); + }); +}); + +describe('isComponent', () => { + it('should return true with a Component', () => { + @Component({}) + class FooComponent {} + + expect(isComponent(FooComponent)).toEqual(true); + }); + + it('should return false with simple class', () => { + class FooPipe {} + + expect(isComponent(FooPipe)).toEqual(false); + }); + it('should return false with Directive', () => { + @Directive() + class FooDirective {} + + expect(isComponent(FooDirective)).toEqual(false); + }); +}); + +describe('isStandaloneComponent', () => { + it('should return true with a Component with "standalone: true"', () => { + @Component({ standalone: true }) + class FooComponent {} + + expect(isStandaloneComponent(FooComponent)).toEqual(true); + }); + + it('should return false with a Component with "standalone: false"', () => { + @Component({ standalone: false }) + class FooComponent {} + + expect(isStandaloneComponent(FooComponent)).toEqual(false); + }); + + it('should return false with a Component without the "standalone" property', () => { + @Component({}) + class FooComponent {} + + expect(isStandaloneComponent(FooComponent)).toEqual(false); + }); + + it('should return false with simple class', () => { + class FooPipe {} + + expect(isStandaloneComponent(FooPipe)).toEqual(false); + }); + + it('should return true with a Directive with "standalone: true"', () => { + @Directive({ standalone: true }) + class FooDirective {} + + expect(isStandaloneComponent(FooDirective)).toEqual(true); + }); + + it('should return false with a Directive with "standalone: false"', () => { + @Directive({ standalone: false }) + class FooDirective {} + + expect(isStandaloneComponent(FooDirective)).toEqual(false); + }); + + it('should return false with Directive without the "standalone" property', () => { + @Directive() + class FooDirective {} + + expect(isStandaloneComponent(FooDirective)).toEqual(false); + }); + + it('should return true with a Pipe with "standalone: true"', () => { + @Pipe({ name: 'FooPipe', standalone: true }) + class FooPipe {} + + expect(isStandaloneComponent(FooPipe)).toEqual(true); + }); + + it('should return false with a Pipe with "standalone: false"', () => { + @Pipe({ name: 'FooPipe', standalone: false }) + class FooPipe {} + + expect(isStandaloneComponent(FooPipe)).toEqual(false); + }); + + it('should return false with Pipe without the "standalone" property', () => { + @Pipe({ + name: 'fooPipe', + }) + class FooPipe {} + + expect(isStandaloneComponent(FooPipe)).toEqual(false); + }); +}); + +describe('getComponentDecoratorMetadata', () => { + it('should return Component with a Component', () => { + @Component({ selector: 'foo' }) + class FooComponent {} + + expect(getComponentDecoratorMetadata(FooComponent)).toBeInstanceOf(Component); + expect(getComponentDecoratorMetadata(FooComponent)).toEqual({ + changeDetection: 1, + selector: 'foo', + }); + }); + + it('should return Component with extending classes', () => { + @Component({ selector: 'bar' }) + class BarComponent {} + @Component({ selector: 'foo' }) + class FooComponent extends BarComponent {} + + expect(getComponentDecoratorMetadata(FooComponent)).toBeInstanceOf(Component); + expect(getComponentDecoratorMetadata(FooComponent)).toEqual({ + changeDetection: 1, + selector: 'foo', + }); + }); +}); + +function sortByPropName( + array: { + propName: string; + templateName: string; + }[] +) { + return array.sort((a, b) => a.propName.localeCompare(b.propName)); +} + +function resolveComponentFactory>(component: T) { + TestBed.configureTestingModule({ + declarations: [component], + }).overrideModule(BrowserDynamicTestingModule, {}); + const componentFactoryResolver = TestBed.inject(ComponentFactoryResolver); + + return componentFactoryResolver.resolveComponentFactory(component); +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.ts new file mode 100644 index 000000000000..da072c2c646d --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.ts @@ -0,0 +1,135 @@ +import type { Type } from '@angular/core'; +import { + Component, + Directive, + Input, + Output, + Pipe, + ɵReflectionCapabilities as ReflectionCapabilities, +} from '@angular/core'; + +const reflectionCapabilities = new ReflectionCapabilities(); + +export type ComponentInputsOutputs = { + inputs: { + propName: string; + templateName: string; + }[]; + outputs: { + propName: string; + templateName: string; + }[]; +}; + +/** Returns component Inputs / Outputs by browsing these properties and decorator */ +export const getComponentInputsOutputs = (component: any): ComponentInputsOutputs => { + const componentMetadata = getComponentDecoratorMetadata(component); + const componentPropsMetadata = getComponentPropsDecoratorMetadata(component); + + const initialValue: ComponentInputsOutputs = { + inputs: [], + outputs: [], + }; + + // Adds the I/O present in @Component metadata + if (componentMetadata && componentMetadata.inputs) { + initialValue.inputs.push( + ...componentMetadata.inputs.map((i) => ({ + propName: typeof i === 'string' ? i : i.name, + templateName: typeof i === 'string' ? i : i.alias, + })) + ); + } + if (componentMetadata && componentMetadata.outputs) { + initialValue.outputs.push( + ...componentMetadata.outputs.map((i) => ({ propName: i, templateName: i })) + ); + } + + if (!componentPropsMetadata) { + return initialValue; + } + + // Browses component properties to extract I/O + // Filters properties that have the same name as the one present in the @Component property + return Object.entries(componentPropsMetadata).reduce((previousValue, [propertyName, values]) => { + const value = values.find((v) => v instanceof Input || v instanceof Output); + if (value instanceof Input) { + const inputToAdd = { + propName: propertyName, + templateName: value.bindingPropertyName ?? value.alias ?? propertyName, + }; + + const previousInputsFiltered = previousValue.inputs.filter( + (i) => i.templateName !== propertyName + ); + return { + ...previousValue, + inputs: [...previousInputsFiltered, inputToAdd], + }; + } + if (value instanceof Output) { + const outputToAdd = { + propName: propertyName, + templateName: value.bindingPropertyName ?? value.alias ?? propertyName, + }; + + const previousOutputsFiltered = previousValue.outputs.filter( + (i) => i.templateName !== propertyName + ); + return { + ...previousValue, + outputs: [...previousOutputsFiltered, outputToAdd], + }; + } + return previousValue; + }, initialValue); +}; + +export const isDeclarable = (component: any): boolean => { + if (!component) { + return false; + } + + const decorators = reflectionCapabilities.annotations(component); + + return !!(decorators || []).find( + (d) => d instanceof Directive || d instanceof Pipe || d instanceof Component + ); +}; + +export const isComponent = (component: any): component is Type => { + if (!component) { + return false; + } + + const decorators = reflectionCapabilities.annotations(component); + + return (decorators || []).some((d) => d instanceof Component); +}; + +export const isStandaloneComponent = (component: any): component is Type => { + if (!component) { + return false; + } + + const decorators = reflectionCapabilities.annotations(component); + + // TODO: `standalone` is only available in Angular v14. Remove cast to `any` once + // Angular deps are updated to v14.x.x. + return (decorators || []).some( + (d) => (d instanceof Component || d instanceof Directive || d instanceof Pipe) && d.standalone + ); +}; + +/** Returns all component decorator properties is used to get all `@Input` and `@Output` Decorator */ +export const getComponentPropsDecoratorMetadata = (component: any) => { + return reflectionCapabilities.propMetadata(component); +}; + +/** Returns component decorator `@Component` */ +export const getComponentDecoratorMetadata = (component: any): Component | undefined => { + const decorators = reflectionCapabilities.annotations(component); + + return decorators.reverse().find((d) => d instanceof Component); +}; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.test.ts new file mode 100644 index 000000000000..1258638e2662 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.test.ts @@ -0,0 +1,26 @@ +import { Component, NgModule } from '@angular/core'; +import { describe, expect, it } from 'vitest'; + +import { isComponentAlreadyDeclared } from './NgModulesAnalyzer'; + +const FooComponent = Component({})(class {}); + +const BarComponent = Component({})(class {}); + +const BetaModule = NgModule({ declarations: [FooComponent] })(class {}); + +const AlphaModule = NgModule({ imports: [BetaModule] })(class {}); + +describe('isComponentAlreadyDeclaredInModules', () => { + it('should return true when the component is already declared in one of modules', () => { + expect(isComponentAlreadyDeclared(FooComponent, [], [AlphaModule])).toEqual(true); + }); + + it('should return true if the component is in moduleDeclarations', () => { + expect(isComponentAlreadyDeclared(BarComponent, [BarComponent], [AlphaModule])).toEqual(true); + }); + + it('should return false if the component is not declared', () => { + expect(isComponentAlreadyDeclared(BarComponent, [], [AlphaModule])).toEqual(false); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.ts new file mode 100644 index 000000000000..bcf9386fb875 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.ts @@ -0,0 +1,55 @@ +import { NgModule, ɵReflectionCapabilities as ReflectionCapabilities } from '@angular/core'; + +const reflectionCapabilities = new ReflectionCapabilities(); + +/** + * Avoid component redeclaration + * + * Checks recursively if the component has already been declared in all import Module + */ +export const isComponentAlreadyDeclared = ( + componentToFind: any, + moduleDeclarations: any[], + moduleImports: any[] +): boolean => { + if ( + moduleDeclarations && + moduleDeclarations.flat().some((declaration) => declaration === componentToFind) + ) { + // Found component in declarations array + return true; + } + if (!moduleImports) { + return false; + } + + return moduleImports.flat().some((importItem) => { + const extractedNgModuleMetadata = extractNgModuleMetadata(importItem); + if (!extractedNgModuleMetadata) { + // Not an NgModule + return false; + } + return isComponentAlreadyDeclared( + componentToFind, + extractedNgModuleMetadata.declarations, + extractedNgModuleMetadata.imports + ); + }); +}; + +const extractNgModuleMetadata = (importItem: any): NgModule => { + const target = importItem && importItem.ngModule ? importItem.ngModule : importItem; + const decorators = reflectionCapabilities.annotations(target); + + if (!decorators || decorators.length === 0) { + return null; + } + + const ngModuleDecorator: NgModule | undefined = decorators.find( + (decorator) => decorator instanceof NgModule + ); + if (!ngModuleDecorator) { + return null; + } + return ngModuleDecorator; +}; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.test.ts new file mode 100644 index 000000000000..e0885569b406 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.test.ts @@ -0,0 +1,201 @@ +import { CommonModule } from '@angular/common'; +import { Component, Directive, Injectable, InjectionToken, NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { + BrowserAnimationsModule, + NoopAnimationsModule, + provideAnimations, + provideNoopAnimations, +} from '@angular/platform-browser/animations'; +import { describe, expect, it, vi } from 'vitest'; + +import type { NgModuleMetadata } from '../../types'; +import { WithOfficialModule } from '../__testfixtures__/test.module'; +import { PropertyExtractor } from './PropertyExtractor'; + +const TEST_TOKEN = new InjectionToken('testToken'); +const TestTokenProvider = { provide: TEST_TOKEN, useValue: 123 }; +const TestService = Injectable()(class {}); +const TestComponent1 = Component({ standalone: false })(class {}); +const TestComponent2 = Component({ standalone: false })(class {}); +const StandaloneTestComponent = Component({})(class {}); +const StandaloneTestDirective = Directive({})(class {}); +const MixedTestComponent1 = Component({})(class extends StandaloneTestComponent {}); +const MixedTestComponent2 = Component({ standalone: false })(class extends MixedTestComponent1 {}); +const MixedTestComponent3 = Component({})(class extends MixedTestComponent2 {}); +const TestModuleWithDeclarations = NgModule({ declarations: [TestComponent1] })(class {}); +const TestModuleWithImportsAndProviders = NgModule({ + imports: [TestModuleWithDeclarations], + providers: [TestTokenProvider], +})(class {}); + +const analyzeMetadata = async (metadata: NgModuleMetadata, component?: any) => { + const propertyExtractor = new PropertyExtractor(metadata, component); + await propertyExtractor.init(); + return propertyExtractor; +}; +const extractImports = async (metadata: NgModuleMetadata, component?: any) => { + const propertyExtractor = new PropertyExtractor(metadata, component); + await propertyExtractor.init(); + return propertyExtractor.imports; +}; +const extractDeclarations = async (metadata: NgModuleMetadata, component?: any) => { + const propertyExtractor = new PropertyExtractor(metadata, component); + await propertyExtractor.init(); + return propertyExtractor.declarations; +}; +const extractProviders = async (metadata: NgModuleMetadata, component?: any) => { + const propertyExtractor = new PropertyExtractor(metadata, component); + await propertyExtractor.init(); + return propertyExtractor.providers; +}; +const extractApplicationProviders = async (metadata: NgModuleMetadata, component?: any) => { + const propertyExtractor = new PropertyExtractor(metadata, component); + await propertyExtractor.init(); + return propertyExtractor.applicationProviders; +}; + +describe('PropertyExtractor', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + describe('analyzeMetadata', () => { + it('should remove BrowserModule', async () => { + const metadata = { + imports: [BrowserModule], + }; + const { imports, providers, applicationProviders } = await analyzeMetadata(metadata); + expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule]); + expect(providers.flat(Number.MAX_VALUE)).toEqual([]); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual([]); + }); + + it('should remove BrowserAnimationsModule and use its providers instead', async () => { + const metadata = { + imports: [BrowserAnimationsModule], + }; + const { imports, providers, applicationProviders } = await analyzeMetadata(metadata); + expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule]); + expect(providers.flat(Number.MAX_VALUE)).toEqual([]); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual(provideAnimations()); + }); + + it('should remove NoopAnimationsModule and use its providers instead', async () => { + const metadata = { + imports: [NoopAnimationsModule], + }; + const { imports, providers, applicationProviders } = await analyzeMetadata(metadata); + expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule]); + expect(providers.flat(Number.MAX_VALUE)).toEqual([]); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual(provideNoopAnimations()); + }); + + it('should remove Browser/Animations modules recursively', async () => { + const metadata = { + imports: [BrowserAnimationsModule, BrowserModule], + }; + const { imports, providers, applicationProviders } = await analyzeMetadata(metadata); + expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule]); + expect(providers.flat(Number.MAX_VALUE)).toEqual([]); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual(provideAnimations()); + }); + + it('should not destructure Angular official module', async () => { + const metadata = { + imports: [WithOfficialModule], + }; + const { imports, providers, applicationProviders } = await analyzeMetadata(metadata); + expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule, WithOfficialModule]); + expect(providers.flat(Number.MAX_VALUE)).toEqual([]); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual([]); + }); + }); + + describe('extractImports', () => { + it('should return Angular official modules', async () => { + const imports = await extractImports({ imports: [TestModuleWithImportsAndProviders] }); + expect(imports).toEqual([CommonModule, TestModuleWithImportsAndProviders]); + }); + + it('should return standalone components', async () => { + const imports = await extractImports( + { + imports: [TestModuleWithImportsAndProviders], + }, + StandaloneTestComponent + ); + expect(imports).toEqual([ + CommonModule, + TestModuleWithImportsAndProviders, + StandaloneTestComponent, + ]); + }); + + it('should return standalone directives', async () => { + const imports = await extractImports( + { + imports: [TestModuleWithImportsAndProviders], + }, + StandaloneTestDirective + ); + expect(imports).toEqual([ + CommonModule, + TestModuleWithImportsAndProviders, + StandaloneTestDirective, + ]); + }); + }); + + describe('extractDeclarations', () => { + it('should return an array of declarations that contains `storyComponent`', async () => { + const declarations = await extractDeclarations( + { declarations: [TestComponent1] }, + TestComponent2 + ); + expect(declarations).toEqual([TestComponent1, TestComponent2]); + }); + }); + + describe('analyzeDecorators', () => { + it('isStandalone should be false', () => { + const { isStandalone } = PropertyExtractor.analyzeDecorators(TestComponent1); + expect(isStandalone).toBe(false); + }); + + it('isStandalone should be true', () => { + const { isStandalone } = PropertyExtractor.analyzeDecorators(StandaloneTestComponent); + expect(isStandalone).toBe(true); + }); + + it('isStandalone should be true', () => { + const { isStandalone } = PropertyExtractor.analyzeDecorators(MixedTestComponent1); + expect(isStandalone).toBe(true); + }); + + it('isStandalone should be false', () => { + const { isStandalone } = PropertyExtractor.analyzeDecorators(MixedTestComponent2); + expect(isStandalone).toBe(false); + }); + + it('isStandalone should be true', () => { + const { isStandalone } = PropertyExtractor.analyzeDecorators(MixedTestComponent3); + expect(isStandalone).toBe(true); + }); + }); + + describe('extractProviders', () => { + it('should return an array of providers', async () => { + const providers = await extractProviders({ + providers: [TestService], + }); + expect(providers).toEqual([TestService]); + }); + + it('should return an array of singletons extracted', async () => { + const singeltons = await extractApplicationProviders({ + imports: [BrowserAnimationsModule], + }); + + expect(singeltons).toEqual(provideAnimations()); + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.ts new file mode 100644 index 000000000000..d2e0ebcbfa58 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.ts @@ -0,0 +1,218 @@ +import { CommonModule } from '@angular/common'; +import type { NgModule, Provider, importProvidersFrom } from '@angular/core'; +import { + Component, + Directive, + Injectable, + InjectionToken, + Input, + Output, + Pipe, + ɵReflectionCapabilities as ReflectionCapabilities, + VERSION, +} from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { dedent } from 'ts-dedent'; + +import type { NgModuleMetadata } from '../../types'; +import { isComponentAlreadyDeclared } from './NgModulesAnalyzer'; + +export const reflectionCapabilities = new ReflectionCapabilities(); +export const REMOVED_MODULES = new InjectionToken('REMOVED_MODULES'); +export const uniqueArray = (arr: any[]) => { + return arr + .flat(Number.MAX_VALUE) + .filter(Boolean) + .filter((value, index, self) => self.indexOf(value) === index); +}; + +export class PropertyExtractor implements NgModuleMetadata { + declarations?: any[] = []; + imports?: any[]; + providers?: Provider[]; + applicationProviders?: Array>; + + constructor( + private metadata: NgModuleMetadata, + private component?: any + ) {} + + // 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 + ` + ) + ); + } + } + + public async init() { + const analyzed = await this.analyzeMetadata(this.metadata); + this.imports = uniqueArray([CommonModule, analyzed.imports]); + this.providers = uniqueArray(analyzed.providers); + this.applicationProviders = uniqueArray(analyzed.applicationProviders); + this.declarations = uniqueArray(analyzed.declarations); + + if (this.component) { + const { isDeclarable, isStandalone } = PropertyExtractor.analyzeDecorators(this.component); + const isDeclared = isComponentAlreadyDeclared( + this.component, + analyzed.declarations, + this.imports + ); + + if (isStandalone) { + this.imports.push(this.component); + } else if (isDeclarable && !isDeclared) { + this.declarations.push(this.component); + } + } + } + + /** + * Analyze NgModule Metadata + * + * - Removes Restricted Imports + * - Extracts providers from ModuleWithProviders + * - Returns a new NgModuleMetadata object + */ + private analyzeMetadata = async (metadata: NgModuleMetadata) => { + const declarations = [...(metadata?.declarations || [])]; + const providers = [...(metadata?.providers || [])]; + const applicationProviders: Provider[] = []; + const imports = await Promise.all( + [...(metadata?.imports || [])].map(async (imported) => { + const [isRestricted, restrictedProviders] = + await PropertyExtractor.analyzeRestricted(imported); + if (isRestricted) { + applicationProviders.unshift(restrictedProviders || []); + return null; + } + return imported; + }) + ).then((results) => results.filter(Boolean)); + + return { ...metadata, imports, providers, applicationProviders, declarations }; + }; + + static analyzeRestricted = async ( + ngModule: NgModule + ): Promise<[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]; + } + + try { + const animations = await import('@angular/platform-browser/animations'); + + if (ngModule === animations.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, animations.provideAnimations()]; + } + + if (ngModule === animations.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, animations.provideNoopAnimations()]; + } + } catch (e) { + return [false]; + } + + return [false]; + }; + + static analyzeDecorators = (component: any) => { + const decorators = reflectionCapabilities.annotations(component); + + const isComponent = decorators.some((d) => this.isDecoratorInstanceOf(d, 'Component')); + const isDirective = decorators.some((d) => this.isDecoratorInstanceOf(d, 'Directive')); + const isPipe = decorators.some((d) => this.isDecoratorInstanceOf(d, 'Pipe')); + + const isDeclarable = isComponent || isDirective || isPipe; + + // Check if the hierarchically lowest Component or Directive decorator (the only relevant for importing dependencies) is standalone. + + let isStandalone = + (isComponent || isDirective) && + [...decorators] + .reverse() // reflectionCapabilities returns decorators in a hierarchically top-down order + .find( + (d) => + this.isDecoratorInstanceOf(d, 'Component') || this.isDecoratorInstanceOf(d, 'Directive') + )?.standalone; + + //Starting in Angular 19 the default (in case it's undefined) value for standalone is true + if (isStandalone === undefined) { + isStandalone = !!(VERSION.major && Number(VERSION.major) >= 19); + } + + return { isDeclarable, isStandalone }; + }; + + static isDecoratorInstanceOf = (decorator: any, name: string) => { + let factory; + switch (name) { + case 'Component': + factory = Component; + break; + case 'Directive': + factory = Directive; + break; + case 'Pipe': + factory = Pipe; + break; + case 'Injectable': + factory = Injectable; + break; + case 'Input': + factory = Input; + break; + case 'Output': + factory = Output; + break; + default: + throw new Error(`Unknown decorator type: ${name}`); + } + return decorator instanceof factory || decorator.ngMetadataName === name; + }; +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/StoryUID.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/StoryUID.ts new file mode 100644 index 000000000000..31912a4748fe --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/StoryUID.ts @@ -0,0 +1,40 @@ +/** Count of stories for each storyId. */ +const storyCounts = new Map(); + +/** + * Increments the count for a storyId and returns the next UID. + * + * When a story is bootstrapped, the storyId is used as the element tag. That becomes an issue when + * a story is rendered multiple times in the same docs page. This function returns a UID that is + * appended to the storyId to make it unique. + * + * @param storyId Id of a story + * @returns Uid of a story + */ +export const getNextStoryUID = (storyId: string): string => { + if (!storyCounts.has(storyId)) { + storyCounts.set(storyId, -1); + } + + const count = storyCounts.get(storyId) + 1; + storyCounts.set(storyId, count); + return `${storyId}-${count}`; +}; + +/** + * Clears the storyId counts. + * + * Can be useful for testing, where you need predictable increments, without reloading the global + * state. + * + * If onlyStoryId is provided, only that storyId is cleared. + * + * @param onlyStoryId Id of a story + */ +export const clearStoryUIDs = (onlyStoryId?: string): void => { + if (onlyStoryId !== undefined && onlyStoryId !== null) { + storyCounts.delete(onlyStoryId); + } else { + storyCounts.clear(); + } +}; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/Zoneless.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/Zoneless.ts new file mode 100644 index 000000000000..f168ceab083d --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/Zoneless.ts @@ -0,0 +1,9 @@ +export const getProvideZonelessChangeDetectionFn = async () => { + const angularCore: any = await import('@angular/core'); + + return 'provideExperimentalZonelessChangeDetection' in angularCore + ? angularCore.provideExperimentalZonelessChangeDetection + : 'provideZonelessChangeDetection' in angularCore + ? angularCore.provideZonelessChangeDetection + : null; +}; diff --git a/code/frameworks/angular-vite/src/client/argsToTemplate.test.ts b/code/frameworks/angular-vite/src/client/argsToTemplate.test.ts new file mode 100644 index 000000000000..51369d2ba5bd --- /dev/null +++ b/code/frameworks/angular-vite/src/client/argsToTemplate.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import type { ArgsToTemplateOptions } from './argsToTemplate'; +import { argsToTemplate } from './argsToTemplate'; + +// adjust path + +describe('argsToTemplate', () => { + it('should correctly convert args to template string and exclude undefined values', () => { + const args: Record = { + prop1: 'value1', + prop2: undefined, + prop3: 'value3', + }; + const options: ArgsToTemplateOptions = {}; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1" [prop3]="prop3"'); + }); + + it('should include properties from include option', () => { + const args = { + prop1: 'value1', + prop2: 'value2', + prop3: 'value3', + }; + const options: ArgsToTemplateOptions = { + include: ['prop1', 'prop3'], + }; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1" [prop3]="prop3"'); + }); + + it('should include non-undefined properties from include option', () => { + const args: Record = { + prop1: 'value1', + prop2: 'value2', + prop3: undefined, + }; + const options: ArgsToTemplateOptions = { + include: ['prop1', 'prop3'], + }; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1"'); + }); + + it('should exclude properties from exclude option', () => { + const args = { + prop1: 'value1', + prop2: 'value2', + prop3: 'value3', + }; + const options: ArgsToTemplateOptions = { + exclude: ['prop2'], + }; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1" [prop3]="prop3"'); + }); + + it('should exclude properties from exclude option and undefined properties', () => { + const args: Record = { + prop1: 'value1', + prop2: 'value2', + prop3: undefined, + }; + const options: ArgsToTemplateOptions = { + exclude: ['prop2'], + }; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1"'); + }); + + it('should prioritize include over exclude when both options are given', () => { + const args = { + prop1: 'value1', + prop2: 'value2', + prop3: 'value3', + }; + const options: ArgsToTemplateOptions = { + include: ['prop1', 'prop2'], + exclude: ['prop2', 'prop3'], + }; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1" [prop2]="prop2"'); + }); + + it('should work when neither include nor exclude options are given', () => { + const args = { + prop1: 'value1', + prop2: 'value2', + }; + const options: ArgsToTemplateOptions = {}; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1" [prop2]="prop2"'); + }); + + it('should bind events correctly when value is a function', () => { + const args = { event1: () => {}, event2: () => {} }; + const result = argsToTemplate(args, {}); + expect(result).toEqual('(event1)="event1($event)" (event2)="event2($event)"'); + }); + + it('should mix properties and events correctly', () => { + const args = { input: 'Value1', event1: () => {} }; + const result = argsToTemplate(args, {}); + expect(result).toEqual('[input]="input" (event1)="event1($event)"'); + }); + + it('should format for non dot notation', () => { + const args = { 'non-dot': 'Value1', 'dash-out': () => {} }; + const result = argsToTemplate(args, {}); + expect(result).toEqual('[non-dot]="this[\'non-dot\']" (dash-out)="this[\'dash-out\']($event)"'); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/argsToTemplate.ts b/code/frameworks/angular-vite/src/client/argsToTemplate.ts new file mode 100644 index 000000000000..351d67f822a6 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/argsToTemplate.ts @@ -0,0 +1,83 @@ +import { formatPropInTemplate } from './angular-beta/ComputesTemplateFromComponent'; + +/** + * Options for controlling the behavior of the argsToTemplate function. + * + * @template T The type of the keys in the target object. + */ +export interface ArgsToTemplateOptions { + /** + * An array of keys to specifically include in the output. If provided, only the keys from this + * array will be included in the output, irrespective of the `exclude` option. Undefined values + * will still be excluded from the output. + */ + include?: Array; + /** + * An array of keys to specifically exclude from the output. If provided, these keys will be + * omitted from the output. This option is ignored if the `include` option is also provided + */ + exclude?: Array; +} + +/** + * Converts an object of arguments to a string of property and event bindings and excludes undefined + * values. Why? Because Angular treats undefined values in property bindings as an actual value and + * does not apply the default value of the property as soon as the binding is set. This feels + * counter-intuitive and is a common source of bugs in stories. + * + * @example + * + * ```ts + * // component.ts + * ㅤ@Component({ selector: 'example' }) + * export class ExampleComponent { + * ㅤ@Input() input1: string = 'Default Input1'; + * ㅤ@Input() input2: string = 'Default Input2'; + * ㅤ@Output() click = new EventEmitter(); + * } + * + * // component.stories.ts + * import { argsToTemplate } from '@storybook/angular'; + * export const Input1: Story = { + * render: (args) => ({ + * props: args, + * // Problem1: + * // This will set input2 to undefined and the internal default value will not be used. + * // Problem2: + * // The default value of input2 will be used, but it is not overridable by the user via controls. + * // Solution: Now the controls will be applicable to both input1 and input2, and the default values will be used if the user does not override them. + * template: ``, + * }), + * args: { + * // In this Story, we want to set the input1 property, and the internal default property of input2 should be used. + * input1: 'Input 1', + * click: { action: 'clicked' }, + * }, + * }; + * ``` + */ +export function argsToTemplate>( + args: A, + options: ArgsToTemplateOptions = {} +) { + const includeSet = options.include ? new Set(options.include) : null; + const excludeSet = options.exclude ? new Set(options.exclude) : null; + + return Object.entries(args) + .filter(([key]) => args[key] !== undefined) + .filter(([key]) => { + if (includeSet) { + return includeSet.has(key); + } + if (excludeSet) { + return !excludeSet.has(key); + } + return true; + }) + .map(([key, value]) => + typeof value === 'function' + ? `(${key})="${formatPropInTemplate(key)}($event)"` + : `[${key}]="${formatPropInTemplate(key)}"` + ) + .join(' '); +} diff --git a/code/frameworks/angular-vite/src/client/compodoc-types.ts b/code/frameworks/angular-vite/src/client/compodoc-types.ts new file mode 100644 index 000000000000..d1ac2f2d52d7 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/compodoc-types.ts @@ -0,0 +1,115 @@ +export interface Method { + name: string; + args: Argument[]; + returnType: string; + decorators?: Decorator[]; + description?: string; + rawdescription?: string; +} + +export interface JsDocTag { + comment?: string; + tagName?: { + escapedText?: string; + }; +} + +export interface Property { + name: string; + decorators?: Decorator[]; + type: string; + optional: boolean; + defaultValue?: string; + description?: string; + rawdescription?: string; + jsdoctags?: JsDocTag[]; +} + +export interface Class { + name: string; + ngname: string; + type: 'pipe'; + properties: Property[]; + methods: Method[]; + description?: string; + rawdescription?: string; +} + +export interface Injectable { + name: string; + type: 'injectable'; + properties: Property[]; + methods: Method[]; + description?: string; + rawdescription?: string; +} + +export interface Pipe { + name: string; + type: 'class'; + properties: Property[]; + methods: Method[]; + description?: string; + rawdescription?: string; +} + +export interface Directive { + name: string; + type: 'directive' | 'component'; + propertiesClass: Property[]; + inputsClass: Property[]; + outputsClass: Property[]; + methodsClass: Method[]; + description?: string; + rawdescription?: string; +} + +export type Component = Directive; + +export interface Argument { + name: string; + type: string; + optional?: boolean; +} + +export interface Decorator { + name: string; +} + +export interface TypeAlias { + name: string; + ctype: string; + subtype: string; + rawtype: string; + file: string; + kind: number; + description?: string; + rawdescription?: string; +} + +export interface EnumType { + name: string; + childs: EnumTypeChild[]; + ctype: string; + subtype: string; + file: string; + description?: string; + rawdescription?: string; +} + +export interface EnumTypeChild { + name: string; + value?: string; +} + +export interface CompodocJson { + directives: Directive[]; + components: Component[]; + pipes: Pipe[]; + injectables: Injectable[]; + classes: Class[]; + miscellaneous?: { + typealiases?: TypeAlias[]; + enumerations?: EnumType[]; + }; +} diff --git a/code/frameworks/angular-vite/src/client/compodoc.test.ts b/code/frameworks/angular-vite/src/client/compodoc.test.ts new file mode 100644 index 000000000000..6f4e16cc5c18 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/compodoc.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { extractType, setCompodocJson } from './compodoc'; +import type { CompodocJson, Decorator } from './compodoc-types'; + +const makeProperty = (compodocType?: string) => ({ + type: compodocType, + name: 'dummy', + decorators: [] as Decorator[], + optional: true, +}); + +const getDummyCompodocJson = () => { + return { + miscellaneous: { + typealiases: [ + { + name: 'EnumAlias', + ctype: 'miscellaneous', + subtype: 'typealias', + rawtype: 'EnumNumeric', + file: 'src/stories/component-with-enums/enums.component.ts', + description: '', + kind: 161, + }, + { + name: 'TypeAlias', + ctype: 'miscellaneous', + subtype: 'typealias', + rawtype: '"Type Alias 1" | "Type Alias 2" | "Type Alias 3"', + file: 'src/stories/component-with-enums/enums.component.ts', + description: '', + kind: 168, + }, + ], + enumerations: [ + { + name: 'EnumNumeric', + childs: [ + { + name: 'FIRST', + }, + { + name: 'SECOND', + }, + { + name: 'THIRD', + }, + ], + ctype: 'miscellaneous', + subtype: 'enum', + description: '

Button Priority

\n', + file: 'src/stories/component-with-enums/enums.component.ts', + }, + { + name: 'EnumNumericInitial', + childs: [ + { + name: 'UNO', + value: '1', + }, + { + name: 'DOS', + }, + { + name: 'TRES', + }, + ], + ctype: 'miscellaneous', + subtype: 'enum', + description: '', + file: 'src/stories/component-with-enums/enums.component.ts', + }, + { + name: 'EnumStringValues', + childs: [ + { + name: 'PRIMARY', + value: 'PRIMARY', + }, + { + name: 'SECONDARY', + value: 'SECONDARY', + }, + { + name: 'TERTIARY', + value: 'TERTIARY', + }, + ], + ctype: 'miscellaneous', + subtype: 'enum', + description: '', + file: 'src/stories/component-with-enums/enums.component.ts', + }, + ], + }, + } as CompodocJson; +}; + +describe('extractType', () => { + describe('with compodoc type', () => { + setCompodocJson(getDummyCompodocJson()); + it.each([ + ['string', { name: 'string' }], + ['boolean', { name: 'boolean' }], + ['number', { name: 'number' }], + // ['object', { name: 'object' }], // seems to be wrong | TODO: REVISIT + // ['foo', { name: 'other', value: 'empty-enum' }], // seems to be wrong | TODO: REVISIT + [null, { name: 'other', value: 'void' }], + [undefined, { name: 'other', value: 'void' }], + // ['T[]', { name: 'other', value: 'empty-enum' }], // seems to be wrong | TODO: REVISIT + ['[]', { name: 'other', value: 'empty-enum' }], + ['"primary" | "secondary"', { name: 'enum', value: ['primary', 'secondary'] }], + ['TypeAlias', { name: 'enum', value: ['Type Alias 1', 'Type Alias 2', 'Type Alias 3'] }], + // ['EnumNumeric', { name: 'other', value: 'empty-enum' }], // seems to be wrong | TODO: REVISIT + // ['EnumNumericInitial', { name: 'other', value: 'empty-enum' }], // seems to be wrong | TODO: REVISIT + ['EnumStringValues', { name: 'enum', value: ['PRIMARY', 'SECONDARY', 'TERTIARY'] }], + ])('%s', (compodocType, expected) => { + expect(extractType(makeProperty(compodocType), null)).toEqual(expected); + }); + }); + + describe('without compodoc type', () => { + it.each([ + ['string', { name: 'string' }], + ['', { name: 'string' }], + [false, { name: 'boolean' }], + [10, { name: 'number' }], + // [['abc'], { name: 'object' }], // seems to be wrong | TODO: REVISIT + // [{ foo: 1 }, { name: 'other', value: 'empty-enum' }], // seems to be wrong | TODO: REVISIT + [undefined, { name: 'other', value: 'void' }], + ])('%s', (defaultValue, expected) => { + expect(extractType(makeProperty(null), defaultValue)).toEqual(expected); + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/compodoc.ts b/code/frameworks/angular-vite/src/client/compodoc.ts new file mode 100644 index 000000000000..5fe06df3a653 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/compodoc.ts @@ -0,0 +1,307 @@ +import { logger } from 'storybook/internal/client-logger'; +import type { ArgTypes, InputType, SBType } from 'storybook/internal/types'; + +import { global } from '@storybook/global'; + +import type { + Argument, + Class, + CompodocJson, + Component, + Directive, + Injectable, + JsDocTag, + Method, + Pipe, + Property, +} from './compodoc-types'; + +const { FEATURES } = global; + +export const isMethod = (methodOrProp: Method | Property): methodOrProp is Method => { + return (methodOrProp as Method).args !== undefined; +}; + +export const setCompodocJson = (compodocJson: CompodocJson) => { + global.__STORYBOOK_COMPODOC_JSON__ = compodocJson; +}; + +export const getCompodocJson = (): CompodocJson => global.__STORYBOOK_COMPODOC_JSON__; + +export const checkValidComponentOrDirective = (component: Component | Directive) => { + if (!component.name) { + throw new Error(`Invalid component ${JSON.stringify(component)}`); + } +}; + +export const checkValidCompodocJson = (compodocJson: CompodocJson) => { + if (!compodocJson || !compodocJson.components) { + throw new Error('Invalid compodoc JSON'); + } +}; + +const hasDecorator = (item: Property, decoratorName: string) => + item.decorators && item.decorators.find((x: any) => x.name === decoratorName); + +const mapPropertyToSection = (item: Property) => { + if (hasDecorator(item, 'ViewChild')) { + return 'view child'; + } + if (hasDecorator(item, 'ViewChildren')) { + return 'view children'; + } + if (hasDecorator(item, 'ContentChild')) { + return 'content child'; + } + if (hasDecorator(item, 'ContentChildren')) { + return 'content children'; + } + return 'properties'; +}; + +const mapItemToSection = (key: string, item: Method | Property): string => { + switch (key) { + case 'methods': + case 'methodsClass': + return 'methods'; + case 'inputsClass': + return 'inputs'; + case 'outputsClass': + return 'outputs'; + case 'properties': + case 'propertiesClass': + if (isMethod(item)) { + throw new Error("Cannot be of type Method if key === 'propertiesClass'"); + } + return mapPropertyToSection(item); + default: + throw new Error(`Unknown key: ${key}`); + } +}; + +export const findComponentByName = (name: string, compodocJson: CompodocJson) => + compodocJson.components.find((c: Component) => c.name === name) || + compodocJson.directives.find((c: Directive) => c.name === name) || + compodocJson.pipes.find((c: Pipe) => c.name === name) || + compodocJson.injectables.find((c: Injectable) => c.name === name) || + compodocJson.classes.find((c: Class) => c.name === name); + +const getComponentData = (component: Component | Directive) => { + if (!component) { + return null; + } + checkValidComponentOrDirective(component); + const compodocJson = getCompodocJson(); + if (!compodocJson) { + return null; + } + checkValidCompodocJson(compodocJson); + const { name } = component; + const metadata = findComponentByName(name, compodocJson); + if (!metadata) { + logger.warn(`Component not found in compodoc JSON: '${name}'`); + } + return metadata; +}; + +const displaySignature = (item: Method): string => { + const args = item.args.map( + (arg: Argument) => `${arg.name}${arg.optional ? '?' : ''}: ${arg.type}` + ); + return `(${args.join(', ')}) => ${item.returnType}`; +}; + +const extractTypeFromValue = (defaultValue: any) => { + const valueType = typeof defaultValue; + return defaultValue || valueType === 'number' || valueType === 'boolean' || valueType === 'string' + ? valueType + : null; +}; + +const extractEnumValues = (compodocType: any) => { + const compodocJson = getCompodocJson(); + const enumType = compodocJson?.miscellaneous?.enumerations?.find((x) => x.name === compodocType); + + if (enumType?.childs.every((x) => x.value)) { + return enumType.childs.map((x) => x.value); + } + + if (typeof compodocType !== 'string' || compodocType.indexOf('|') === -1) { + return null; + } + + try { + return compodocType.split('|').map((value) => JSON.parse(value)); + } catch (e) { + return null; + } +}; + +export const extractType = (property: Property, defaultValue: any): SBType => { + const compodocType = property.type || extractTypeFromValue(defaultValue); + switch (compodocType) { + case 'string': + case 'boolean': + case 'number': + return { name: compodocType }; + case undefined: + case null: + return { name: 'other', value: 'void' }; + default: { + const resolvedType = resolveTypealias(compodocType); + const enumValues = extractEnumValues(resolvedType); + return enumValues + ? { name: 'enum', value: enumValues } + : { name: 'other', value: 'empty-enum' }; + } + } +}; + +const castDefaultValue = (property: Property, defaultValue: any) => { + const compodocType = property.type; + + // All these checks are necessary as compodoc does not always set the type ie. @HostBinding have empty types. + // null and undefined also have 'any' type + if (['boolean', 'number', 'string', 'EventEmitter'].includes(compodocType)) { + switch (compodocType) { + case 'boolean': + return defaultValue === 'true'; + case 'number': + return Number(defaultValue); + case 'EventEmitter': + return undefined; + default: + return defaultValue; + } + } else { + switch (defaultValue) { + case 'true': + return true; + case 'false': + return false; + case 'null': + return null; + case 'undefined': + return undefined; + default: + return defaultValue; + } + } +}; + +const extractDefaultValueFromComments = (property: Property, value: any) => { + let commentValue = value; + property.jsdoctags.forEach((tag: JsDocTag) => { + if (['default', 'defaultvalue'].includes(tag.tagName.escapedText)) { + const dom = new global.DOMParser().parseFromString(tag.comment, 'text/html'); + commentValue = dom.body.textContent; + } + }); + return commentValue; +}; + +const extractDefaultValue = (property: Property) => { + try { + let value: string = property.defaultValue?.replace(/^'(.*)'$/, '$1'); + value = castDefaultValue(property, value); + + if (value == null && property.jsdoctags?.length > 0) { + value = extractDefaultValueFromComments(property, value); + } + + return value; + } catch (err) { + logger.debug(`Error extracting ${property.name}: ${property.defaultValue}`); + return undefined; + } +}; + +const resolveTypealias = (compodocType: string): string => { + const compodocJson = getCompodocJson(); + const typeAlias = compodocJson?.miscellaneous?.typealiases?.find((x) => x.name === compodocType); + return typeAlias ? resolveTypealias(typeAlias.rawtype) : compodocType; +}; + +export const extractArgTypesFromData = (componentData: Class | Directive | Injectable | Pipe) => { + const sectionToItems: Record = {}; + const componentClasses = FEATURES.angularFilterNonInputControls + ? ['inputsClass'] + : ['propertiesClass', 'methodsClass', 'inputsClass', 'outputsClass']; + const compodocClasses = ['component', 'directive'].includes(componentData.type) + ? componentClasses + : ['properties', 'methods']; + + type COMPODOC_CLASS = + | 'properties' + | 'methods' + | 'propertiesClass' + | 'methodsClass' + | 'inputsClass' + | 'outputsClass'; + + compodocClasses.forEach((key: COMPODOC_CLASS) => { + const data = (componentData as any)[key] || []; + data.forEach((item: Method | Property) => { + const section = mapItemToSection(key, item); + const defaultValue = isMethod(item) ? undefined : extractDefaultValue(item as Property); + + const type: SBType = + isMethod(item) || (section !== 'inputs' && section !== 'properties') + ? { name: 'other', value: 'void' } + : extractType(item as Property, defaultValue); + const action = section === 'outputs' ? { action: item.name } : {}; + + const argType = { + name: item.name, + description: item.rawdescription || item.description, + type, + ...action, + table: { + category: section, + type: { + summary: isMethod(item) ? displaySignature(item) : item.type, + required: isMethod(item) ? false : !item.optional, + }, + defaultValue: { summary: defaultValue }, + }, + }; + + if (!sectionToItems[section]) { + sectionToItems[section] = []; + } + sectionToItems[section].push(argType); + }); + }); + + const SECTIONS = [ + 'properties', + 'inputs', + 'outputs', + 'methods', + 'view child', + 'view children', + 'content child', + 'content children', + ]; + const argTypes: ArgTypes = {}; + SECTIONS.forEach((section) => { + const items = sectionToItems[section]; + if (items) { + items.forEach((argType) => { + argTypes[argType.name] = argType; + }); + } + }); + + return argTypes; +}; + +export const extractArgTypes = (component: Component | Directive) => { + const componentData = getComponentData(component); + return componentData && extractArgTypesFromData(componentData); +}; + +export const extractComponentDescription = (component: Component | Directive) => { + const componentData = getComponentData(component); + return componentData && (componentData.rawdescription || componentData.description); +}; diff --git a/code/frameworks/angular-vite/src/client/config.ts b/code/frameworks/angular-vite/src/client/config.ts new file mode 100644 index 000000000000..0cc92ae39379 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/config.ts @@ -0,0 +1,20 @@ +import './globals'; + +export { render, renderToCanvas } from './render'; +export { decorateStory as applyDecorators } from './decorateStory'; + +import { enhanceArgTypes } from 'storybook/internal/docs-tools'; +import type { ArgTypesEnhancer, Parameters } from 'storybook/internal/types'; + +import { extractArgTypes, extractComponentDescription } from './compodoc'; + +export const parameters: Parameters = { + renderer: 'angular', + docs: { + story: { inline: true }, + extractArgTypes, + extractComponentDescription, + }, +}; + +export const argTypesEnhancers: ArgTypesEnhancer[] = [enhanceArgTypes]; diff --git a/code/frameworks/angular-vite/src/client/csf-factories.test.ts b/code/frameworks/angular-vite/src/client/csf-factories.test.ts new file mode 100644 index 000000000000..8e2ab022256b --- /dev/null +++ b/code/frameworks/angular-vite/src/client/csf-factories.test.ts @@ -0,0 +1,371 @@ +// this file primarily tests TypeScript types with some runtime assertions +import { Component, EventEmitter, input, Input, output, Output } from '@angular/core'; +import { describe, expect, it, test } from 'vitest'; + +import type { Args } from 'storybook/internal/types'; + +import { __definePreview } from './preview'; +import type { Decorator } from './public-types'; + +@Component({ + selector: 'storybook-button', + standalone: true, + template: ``, +}) +class ButtonComponent { + @Input() + label!: string; + + @Input() + disabled!: boolean; + + @Output() + disabledChange = new EventEmitter(); +} + +type ButtonProps = { label: string; disabled: boolean; disabledChange?: (e: void) => void }; + +const preview = __definePreview({ + addons: [], +}); + +test('csf factories', () => { + const meta = preview.meta({ + component: ButtonComponent, + args: { disabled: false }, + }); + + const MyStory = meta.story({ + args: { + label: 'Hello world', + }, + }); + + expect(MyStory.input.args?.label).toBe('Hello world'); +}); + +describe('Args can be provided in multiple ways', () => { + it('✅ All required args may be provided in meta', () => { + const meta = preview.meta({ + component: ButtonComponent, + args: { disabled: false }, + }); + + const Basic = meta.story({ + args: {}, + }); + }); + + it('✅ Required args may be provided partial in meta and the story', () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { label: 'good' }, + }); + const Basic = meta.story({ + args: { disabled: false }, + }); + }); + + it('❌ The combined shape of meta args and story args must match the required args.', () => { + { + const meta = preview.type<{ args: { disabled: boolean } }>().meta({ + component: ButtonComponent, + }); + // @ts-expect-error disabled not provided ❌ + const Basic = meta.story({ + args: { label: 'good' }, + }); + } + { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { label: 'good' }, + }); + // @ts-expect-error disabled not provided ❌ + const Basic = meta.story(); + } + { + const meta = preview.type<{ args: ButtonProps }>().meta({ component: ButtonComponent }); + // @ts-expect-error disabled not provided ❌ + const Basic = meta.story({ + args: { label: 'good' }, + }); + } + }); + + it("✅ Required args don't need to be provided when the user uses an empty render", () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { label: 'good' }, + }); + const Basic = meta.story({ + render: () => ({ template: '
Hello world
' }), + }); + + const CSF1 = meta.story(() => ({ template: '
Hello world
' })); + }); + + it('❌ Required args need to be provided when the user uses a non-empty render', () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { label: 'good' }, + }); + // @ts-expect-error disabled not provided ❌ + const Basic = meta.story({ + args: { + label: 'good', + }, + render: (args) => ({ template: '
Hello world
' }), + }); + }); +}); + +type ThemeData = 'light' | 'dark'; + +describe('Story args can be inferred', () => { + it('Correct args are inferred when type is widened for render function', () => { + const meta = preview.type<{ args: { theme: ThemeData } }>().meta({ + component: ButtonComponent, + args: { disabled: false }, + render: (args) => { + return { + template: `
+ +
`, + props: args, + }; + }, + }); + + const Basic = meta.story({ args: { theme: 'light', label: 'good' } }); + }); + + const withDecorator: Decorator<{ decoratorArg: number }> = (storyFunc, { args }) => { + const story = storyFunc(); + return { + ...story, + template: `
Decorator: ${args.decoratorArg}
${story.template}
`, + }; + }; + + it('Correct args are inferred when type is widened for decorators', () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { disabled: false }, + decorators: [withDecorator], + }); + + const Basic = meta.story({ args: { decoratorArg: 0, label: 'good' } }); + }); + + it('Correct args are inferred when type is widened for multiple decorators', () => { + const secondDecorator: Decorator<{ decoratorArg2: string }> = (storyFunc, { args }) => { + const story = storyFunc(); + return { + ...story, + template: `
Decorator: ${args.decoratorArg2}
${story.template}
`, + }; + }; + + // decorator is not using args + const thirdDecorator: Decorator = (storyFunc) => { + const story = storyFunc(); + return { + ...story, + template: `
${story.template}
`, + }; + }; + + // decorator is not using args + const fourthDecorator: Decorator = (storyFunc) => { + const story = storyFunc(); + return { + ...story, + template: `
${story.template}
`, + }; + }; + + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { disabled: false }, + decorators: [withDecorator, secondDecorator, thirdDecorator, fourthDecorator], + }); + + const Basic = meta.story({ + args: { decoratorArg: 0, decoratorArg2: '', label: 'good' }, + }); + }); + + it('Component type can be overridden', () => { + const meta = preview + .type<{ args: Omit & { disabledChange?: boolean } }>() + .meta({ + render: ({ disabledChange, ...args }) => { + return { + template: ``, + props: { + ...args, + onDisabledChangeHandler: disabledChange ? () => {} : undefined, + }, + }; + }, + args: { label: 'hello', disabledChange: false }, + }); + + const Basic = meta.story({ + args: { + disabled: false, + }, + }); + const WithHandler = meta.story({ args: { disabled: false, disabledChange: true } }); + }); + + it('Correct args are inferred when type is added in renderer', () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { label: 'hello', disabledChangeToggle: false }, + render: ({ + disabledChangeToggle, + ...args + }: ButtonProps & { disabledChangeToggle?: boolean }) => { + return { + template: ``, + props: { + ...args, + onDisabledChangeHandler: disabledChangeToggle ? () => {} : undefined, + }, + }; + }, + }); + + const Basic = meta.story({ args: { disabled: false } }); + const WithHandler = meta.story({ args: { disabled: false, disabledChangeToggle: true } }); + }); + + it('Correct args are inferred when render arg type is required', () => { + const meta = preview.type<{ args: { disabledChangeToggle: boolean } }>().meta({ + component: ButtonComponent, + args: { label: 'hello' }, + render: (args) => { + return { + template: ``, + props: { + ...args, + onDisabledChangeHandler: args.disabledChangeToggle ? () => {} : undefined, + }, + }; + }, + }); + + // @ts-expect-error disabledChangeToggle is required + const Basic = meta.story({ args: { disabled: false } }); + const WithHandler = meta.story({ args: { disabled: false, disabledChangeToggle: true } }); + }); + + it('args can be reused', () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + }); + + const Enabled = meta.story({ args: { label: 'hello', disabled: false } }); + const Disabled = meta.story({ args: { ...Enabled.input.args, disabled: true } }); + }); + + it('stories can be extended', () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + }); + + const Enabled = meta.story({ args: { label: 'hello', disabled: false } }); + const Disabled = Enabled.extend({ args: { disabled: true } }); + }); +}); + +it('Components without Props can be used', () => { + @Component({ + selector: 'storybook-simple', + standalone: true, + template: `
Simple
`, + }) + class SimpleComponent {} + + const withDecorator: Decorator = (storyFunc) => { + const story = storyFunc(); + return { + ...story, + template: `
${story.template}
`, + }; + }; + + const meta = preview.meta({ + component: SimpleComponent, + decorators: [withDecorator], + }); + + const Basic = meta.story(); +}); + +it('Signal components can be used', () => { + @Component({ + standalone: false, + // Needs to be a different name to the CLI template button + selector: 'storybook-signal-button', + template: ` `, + }) + class SignalButtonComponent { + /** Is this the principal call to action on the page? */ + primary = input(false); + + /** What background color to use */ + @Input() + backgroundColor?: string; + + /** How large should the button be? */ + size = input('medium', { + transform: (val: 'small' | 'medium') => val, + }); + + /** Button contents */ + label = input.required(); + + /** Optional click handler */ + onClick = output(); + + public get classes(): string[] { + const mode = this.primary() ? 'storybook-button--primary' : 'storybook-button--secondary'; + + return ['storybook-button', `storybook-button--${this.size()}`, mode]; + } + } + + const meta = preview.meta({ + component: SignalButtonComponent, + }); + + const Basic = meta.story({ + args: { + backgroundColor: 'red', + size: 'small', + label: '1', + }, + }); +}); diff --git a/code/frameworks/angular-vite/src/client/decorateStory.test.ts b/code/frameworks/angular-vite/src/client/decorateStory.test.ts new file mode 100644 index 000000000000..112c36eb0cfa --- /dev/null +++ b/code/frameworks/angular-vite/src/client/decorateStory.test.ts @@ -0,0 +1,345 @@ +import { Component, Input, Output } from '@angular/core'; +import type { DecoratorFunction, StoryContext } from 'storybook/internal/types'; +import { describe, expect, it } from 'vitest'; +import { componentWrapperDecorator } from './decorators'; + +import decorateStory from './decorateStory'; +import type { AngularRenderer } from './types'; + +// TODO: Fix. Test is infinitely running. +describe.skip('decorateStory', () => { + describe('angular behavior', () => { + it('should use componentWrapperDecorator with args', () => { + const decorators: DecoratorFunction[] = [ + componentWrapperDecorator(ParentComponent, ({ args }) => args), + componentWrapperDecorator( + (story) => `${story}`, + ({ args }) => args + ), + componentWrapperDecorator((story) => `${story}`), + ]; + const decorated = decorateStory(() => ({ template: '' }), decorators); + + expect( + decorated( + makeContext({ + component: FooComponent, + args: { + parentInput: 'Parent input', + grandparentInput: 'grandparent input', + parentOutput: () => {}, + }, + }) + ) + ).toEqual({ + props: { + parentInput: 'Parent input', + grandparentInput: 'grandparent input', + parentOutput: expect.any(Function), + }, + template: + '', + userDefinedTemplate: true, + }); + }); + + it('should use componentWrapperDecorator with input / output', () => { + const decorators: DecoratorFunction[] = [ + componentWrapperDecorator(ParentComponent, { + parentInput: 'Parent input', + parentOutput: () => {}, + }), + componentWrapperDecorator( + (story) => `${story}`, + { + grandparentInput: 'Grandparent input', + sameInput: 'Should be override by story props', + } + ), + componentWrapperDecorator((story) => `${story}`), + ]; + const decorated = decorateStory( + () => ({ template: '', props: { sameInput: 'Story input' } }), + decorators + ); + + expect( + decorated( + makeContext({ + component: FooComponent, + }) + ) + ).toEqual({ + props: { + parentInput: 'Parent input', + parentOutput: expect.any(Function), + grandparentInput: 'Grandparent input', + sameInput: 'Story input', + }, + template: + '', + userDefinedTemplate: true, + }); + }); + + it('should use componentWrapperDecorator', () => { + const decorators: DecoratorFunction[] = [ + componentWrapperDecorator(ParentComponent), + componentWrapperDecorator((story) => `${story}`), + componentWrapperDecorator((story) => `${story}`), + ]; + const decorated = decorateStory(() => ({ template: '' }), decorators); + + expect(decorated(makeContext({ component: FooComponent }))).toEqual({ + template: + '', + userDefinedTemplate: true, + }); + }); + + it('should use template in preference to component parameters', () => { + const decorators: DecoratorFunction[] = [ + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + ]; + const decorated = decorateStory(() => ({ template: '' }), decorators); + + expect(decorated(makeContext({ component: FooComponent }))).toEqual({ + template: + '', + userDefinedTemplate: true, + }); + }); + + it('should include story templates in decorators', () => { + const decorators: DecoratorFunction[] = [ + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + ]; + const decorated = decorateStory(() => ({ template: '' }), decorators); + + expect(decorated(makeContext({}))).toEqual({ + template: + '', + userDefinedTemplate: true, + }); + }); + + it('should include story components in decorators', () => { + const decorators: DecoratorFunction[] = [ + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + ]; + const decorated = decorateStory(() => ({}), decorators); + + expect(decorated(makeContext({ component: FooComponent }))).toEqual({ + template: + '', + userDefinedTemplate: false, + }); + }); + + it('should keep template with an empty value', () => { + const decorators: DecoratorFunction[] = [ + componentWrapperDecorator(ParentComponent), + ]; + const decorated = decorateStory(() => ({ template: '' }), decorators); + + expect(decorated(makeContext({ component: FooComponent }))).toEqual({ + template: '', + }); + }); + + it('should only keeps args with a control or an action in argTypes', () => { + const decorated = decorateStory( + (context: StoryContext) => ({ + template: `Args available in the story : ${Object.keys(context.args).join()}`, + }), + [] + ); + + expect( + decorated( + makeContext({ + component: FooComponent, + argTypes: { + withControl: { control: { type: 'object' }, name: 'withControl' }, + withAction: { action: 'onClick', name: 'withAction' }, + toRemove: { name: 'toRemove' }, + }, + args: { + withControl: 'withControl', + withAction: () => ({}), + toRemove: 'toRemove', + }, + }) + ) + ).toEqual({ + template: 'Args available in the story : withControl,withAction', + userDefinedTemplate: true, + }); + }); + }); + + describe('default behavior', () => { + it('calls decorators in out to in order', () => { + const decorators: DecoratorFunction[] = [ + (s) => { + const story = s(); + return { ...story, props: { a: [...story.props.a, 1] } }; + }, + (s) => { + const story = s(); + return { ...story, props: { a: [...story.props.a, 2] } }; + }, + (s) => { + const story = s(); + return { ...story, props: { a: [...story.props.a, 3] } }; + }, + ]; + const decorated = decorateStory(() => ({ props: { a: [0] } }), decorators); + + expect(decorated(makeContext({}))).toEqual({ props: { a: [0, 1, 2, 3] } }); + }); + + it('passes context through to sub decorators', () => { + const decorators: DecoratorFunction[] = [ + (s, c) => { + const story = s({ ...c, k: 1 }); + return { ...story, props: { a: [...story.props.a, c.k] } }; + }, + (s, c) => { + const story = s({ ...c, k: 2 }); + return { ...story, props: { a: [...story.props.a, c.k] } }; + }, + (s, c) => { + const story = s({ ...c, k: 3 }); + return { ...story, props: { a: [...story.props.a, c.k] } }; + }, + ]; + const decorated = decorateStory((c: StoryContext) => ({ props: { a: [c.k] } }), decorators); + + expect(decorated(makeContext({ k: 0 }))).toEqual({ props: { a: [1, 2, 3, 0] } }); + }); + + it('DOES NOT merge parameter or pass through parameters key in context', () => { + const decorators: DecoratorFunction[] = [ + (s, c) => { + const story = s({ ...c, k: 1, parameters: { p: 1 } }); + return { + ...story, + props: { a: [...story.props.a, c.k], p: [...story.props.p, c.parameters.p] }, + }; + }, + (s, c) => { + const story = s({ ...c, k: 2, parameters: { p: 2 } }); + return { + ...story, + props: { a: [...story.props.a, c.k], p: [...story.props.p, c.parameters.p] }, + }; + }, + (s, c) => { + const story = s({ ...c, k: 3, parameters: { p: 3 } }); + return { + ...story, + props: { a: [...story.props.a, c.k], p: [...story.props.p, c.parameters.p] }, + }; + }, + ]; + const decorated = decorateStory( + (c: StoryContext) => ({ props: { a: [c.k], p: [c.parameters.p] } }), + decorators + ); + + expect(decorated(makeContext({ k: 0, parameters: { p: 0 } }))).toEqual({ + props: { a: [1, 2, 3, 0], p: [0, 0, 0, 0] }, + }); + }); + }); +}); + +function makeContext(input: Record): StoryContext { + return { + id: 'id', + kind: 'kind', + name: 'name', + viewMode: 'story', + parameters: {}, + ...input, + } as StoryContext; +} + +@Component({ + selector: 'foo', + template: `foo`, +}) +class FooComponent {} + +@Component({ + selector: 'parent', + template: ``, +}) +class ParentComponent { + @Input() + parentInput: string; + + @Output() + parentOutput: any; +} diff --git a/code/frameworks/angular-vite/src/client/decorateStory.ts b/code/frameworks/angular-vite/src/client/decorateStory.ts new file mode 100644 index 000000000000..9311afc0fa94 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/decorateStory.ts @@ -0,0 +1,71 @@ +import { sanitizeStoryContextUpdate } from 'storybook/preview-api'; +import type { DecoratorFunction, LegacyStoryFn, StoryContext } from 'storybook/internal/types'; + +import { computesTemplateFromComponent } from './angular-beta/ComputesTemplateFromComponent'; +import type { AngularRenderer } from './types'; + +export default function decorateStory( + mainStoryFn: LegacyStoryFn, + decorators: DecoratorFunction[] +): LegacyStoryFn { + const returnDecorators = [cleanArgsDecorator, ...decorators].reduce( + (previousStoryFn: LegacyStoryFn, decorator) => + (context: StoryContext) => { + const decoratedStory = decorator((update) => { + return previousStoryFn({ + ...context, + ...sanitizeStoryContextUpdate(update), + }); + }, context); + + return decoratedStory; + }, + (context) => prepareMain(mainStoryFn(context), context) + ); + + return returnDecorators; +} + +export { decorateStory }; + +const prepareMain = ( + story: AngularRenderer['storyResult'], + context: StoryContext +): AngularRenderer['storyResult'] => { + let { template } = story; + + const { component } = context; + const userDefinedTemplate = !hasNoTemplate(template); + + if (!userDefinedTemplate && component) { + template = computesTemplateFromComponent(component, story.props, ''); + } + return { + ...story, + ...(template ? { template, userDefinedTemplate } : {}), + }; +}; + +function hasNoTemplate(template: string | null | undefined): template is undefined { + return template === null || template === undefined; +} + +const cleanArgsDecorator: DecoratorFunction = (storyFn, context) => { + if (!context.argTypes || !context.args) { + return storyFn(); + } + + const argsToClean = context.args; + + context.args = Object.entries(argsToClean).reduce((obj, [key, arg]) => { + const argType = context.argTypes[key]; + + // Only keeps args with a control or an action in argTypes + if (argType?.action || argType?.control) { + return { ...obj, [key]: arg }; + } + return obj; + }, {}); + + return storyFn(); +}; diff --git a/code/frameworks/angular-vite/src/client/decorators.test.ts b/code/frameworks/angular-vite/src/client/decorators.test.ts new file mode 100644 index 000000000000..a8f99edeaf64 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/decorators.test.ts @@ -0,0 +1,179 @@ +import type { Addon_StoryContext } from 'storybook/internal/types'; + +import { vi, expect, describe, it } from 'vitest'; +import { Component } from '@angular/core'; +import { moduleMetadata, applicationConfig } from './decorators'; +import type { AngularRenderer } from './types'; + +const defaultContext: Addon_StoryContext = { + componentId: 'unspecified', + kind: 'unspecified', + title: 'unspecified', + id: 'unspecified', + name: 'unspecified', + story: 'unspecified', + tags: [], + parameters: {}, + initialArgs: {}, + args: {}, + argTypes: {}, + globals: {}, + globalTypes: {}, + storyGlobals: {}, + reporting: { + reports: [], + addReport: vi.fn(), + }, + hooks: {}, + loaded: {}, + originalStoryFn: vi.fn(), + viewMode: 'story', + abortSignal: undefined, + canvasElement: undefined, + step: undefined, + context: undefined, + canvas: undefined, + userEvent: undefined, + mount: undefined, +}; + +defaultContext.context = defaultContext; + +class MockModule {} +class MockModuleTwo {} +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({ + imports: [MockModule], + providers: [MockService], + })( + () => ({}), + // deepscan-disable-next-line + defaultContext + ); + + expect(result).toEqual({ + moduleMetadata: { + declarations: [], + entryComponents: [], + imports: [MockModule], + schemas: [], + providers: [MockService], + }, + }); + }); + + it('should combine with individual metadata on a story', () => { + const result = moduleMetadata({ + imports: [MockModule], + })( + () => ({ + component: MockComponent, + moduleMetadata: { + imports: [MockModuleTwo], + providers: [MockService], + }, + }), + // deepscan-disable-next-line + defaultContext + ); + + expect(result).toEqual({ + component: MockComponent, + moduleMetadata: { + declarations: [], + entryComponents: [], + imports: [MockModule, MockModuleTwo], + schemas: [], + providers: [MockService], + }, + }); + }); + + it('should return the original metadata if passed null', () => { + const result = moduleMetadata(null)( + () => ({ + component: MockComponent, + moduleMetadata: { + providers: [MockService], + }, + }), + // deepscan-disable-next-line + defaultContext + ); + + expect(result).toEqual({ + component: MockComponent, + moduleMetadata: { + declarations: [], + entryComponents: [], + imports: [], + schemas: [], + providers: [MockService], + }, + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/decorators.ts b/code/frameworks/angular-vite/src/client/decorators.ts new file mode 100644 index 000000000000..f214c708e611 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/decorators.ts @@ -0,0 +1,85 @@ +import type { DecoratorFunction, StoryContext } from 'storybook/internal/types'; + +import type { ApplicationConfig, Type } from '@angular/core'; + +import { computesTemplateFromComponent } from './angular-beta/ComputesTemplateFromComponent'; +import { isComponent } from './angular-beta/utils/NgComponentAnalyzer'; +import type { AngularRenderer, ICollection, NgModuleMetadata } from './types'; + +// We use `any` here as the default type rather than `Args` because we need something that is +// castable to any component-specific args type when the user is being careful. +export const moduleMetadata = + (metadata: Partial): DecoratorFunction => + (storyFn) => { + const story = storyFn(); + const storyMetadata = story.moduleMetadata || {}; + metadata = metadata || {}; + + return { + ...story, + moduleMetadata: { + declarations: [...(metadata.declarations || []), ...(storyMetadata.declarations || [])], + entryComponents: [ + ...(metadata.entryComponents || []), + ...(storyMetadata.entryComponents || []), + ], + imports: [...(metadata.imports || []), ...(storyMetadata.imports || [])], + schemas: [...(metadata.schemas || []), ...(storyMetadata.schemas || [])], + providers: [...(metadata.providers || []), ...(storyMetadata.providers || [])], + }, + }; + }; + +/** + * 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), + props?: ICollection | ((storyContext: StoryContext) => ICollection) + ): DecoratorFunction => + (storyFn, storyContext) => { + const story = storyFn(); + const currentProps = typeof props === 'function' ? (props(storyContext) as ICollection) : props; + + const template = isComponent(element) + ? computesTemplateFromComponent(element, currentProps ?? {}, story.template) + : element(story.template); + + return { + ...story, + template, + ...(currentProps || story.props + ? { + props: { + ...currentProps, + ...story.props, + }, + } + : {}), + }; + }; diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/argtypes.snapshot b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/argtypes.snapshot new file mode 100644 index 000000000000..d3625f14e38f --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/argtypes.snapshot @@ -0,0 +1,441 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`angular component properties doc-button 1`] = ` +Object { + "_inputValue": Object { + "defaultValue": "some value", + "description": "", + "name": "_inputValue", + "table": Object { + "category": "properties", + "defaultValue": Object { + "summary": "some value", + }, + "type": Object { + "required": true, + "summary": "string", + }, + }, + "type": Object { + "name": "string", + }, + }, + "_value": Object { + "defaultValue": "Private hello", + "description": " +Private value.", + "name": "_value", + "table": Object { + "category": "properties", + "defaultValue": Object { + "summary": "Private hello", + }, + "type": Object { + "required": true, + "summary": "string", + }, + }, + "type": Object { + "name": "string", + }, + }, + "accent": Object { + "defaultValue": undefined, + "description": " +Specify the accent-type of the button", + "name": "accent", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "ButtonAccent", + }, + }, + "type": Object { + "name": "object", + }, + }, + "appearance": Object { + "defaultValue": "secondary", + "description": " +Appearance style of the button.", + "name": "appearance", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": "secondary", + }, + "type": Object { + "required": true, + "summary": "\\"primary\\" | \\"secondary\\"", + }, + }, + "type": Object { + "name": "enum", + "value": Array [ + "primary", + "secondary", + ], + }, + }, + "buttonRef": Object { + "defaultValue": undefined, + "description": "", + "name": "buttonRef", + "table": Object { + "category": "view child", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "ElementRef", + }, + }, + "type": Object { + "name": "void", + }, + }, + "calc": Object { + "defaultValue": undefined, + "description": " + +An internal calculation method which adds \`x\` and \`y\` together. + +", + "name": "calc", + "table": Object { + "category": "methods", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": false, + "summary": "(x: number, y: string | number) => number", + }, + }, + "type": Object { + "name": "void", + }, + }, + "focus": Object { + "defaultValue": false, + "description": "", + "name": "focus", + "table": Object { + "category": "properties", + "defaultValue": Object { + "summary": false, + }, + "type": Object { + "required": true, + "summary": "", + }, + }, + "type": Object { + "name": "boolean", + }, + }, + "inputValue": Object { + "defaultValue": undefined, + "description": " +Setter for \`inputValue\` that is also an \`@Input\`.", + "name": "inputValue", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "string", + }, + }, + "type": Object { + "name": "string", + }, + }, + "internalProperty": Object { + "defaultValue": "Public hello", + "description": " +Public value.", + "name": "internalProperty", + "table": Object { + "category": "properties", + "defaultValue": Object { + "summary": "Public hello", + }, + "type": Object { + "required": true, + "summary": "string", + }, + }, + "type": Object { + "name": "string", + }, + }, + "isDisabled": Object { + "defaultValue": false, + "description": " +Sets the button to a disabled state.", + "name": "isDisabled", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": false, + }, + "type": Object { + "required": true, + "summary": "boolean", + }, + }, + "type": Object { + "name": "boolean", + }, + }, + "item": Object { + "defaultValue": undefined, + "description": undefined, + "name": "item", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "T[]", + }, + }, + "type": Object { + "name": "object", + }, + }, + "label": Object { + "defaultValue": undefined, + "description": " + +The inner text of the button. + +", + "name": "label", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "string", + }, + }, + "type": Object { + "name": "string", + }, + }, + "onClick": Object { + "action": "onClick", + "defaultValue": undefined, + "description": " + +Handler to be called when the button is clicked by a user. + +Will also block the emission of the event if \`isDisabled\` is true. +", + "name": "onClick", + "table": Object { + "category": "outputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "EventEmitter", + }, + }, + "type": Object { + "name": "void", + }, + }, + "onClickListener": Object { + "defaultValue": undefined, + "description": undefined, + "name": "onClickListener", + "table": Object { + "category": "methods", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": false, + "summary": "(btn: ) => void", + }, + }, + "type": Object { + "name": "void", + }, + }, + "privateMethod": Object { + "defaultValue": undefined, + "description": " + +A private method. + +", + "name": "privateMethod", + "table": Object { + "category": "methods", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": false, + "summary": "(password: string) => void", + }, + }, + "type": Object { + "name": "void", + }, + }, + "processedItem": Object { + "defaultValue": undefined, + "description": "", + "name": "processedItem", + "table": Object { + "category": "properties", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "T[]", + }, + }, + "type": Object { + "name": "object", + }, + }, + "protectedMethod": Object { + "defaultValue": undefined, + "description": " + +A protected method. + +", + "name": "protectedMethod", + "table": Object { + "category": "methods", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": false, + "summary": "(id?: number) => void", + }, + }, + "type": Object { + "name": "void", + }, + }, + "publicMethod": Object { + "defaultValue": undefined, + "description": " +A public method using an interface.", + "name": "publicMethod", + "table": Object { + "category": "methods", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": false, + "summary": "(things: ISomeInterface) => void", + }, + }, + "type": Object { + "name": "void", + }, + }, + "showKeyAlias": Object { + "defaultValue": undefined, + "description": undefined, + "name": "showKeyAlias", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "", + }, + }, + "type": Object { + "name": "void", + }, + }, + "size": Object { + "defaultValue": "medium", + "description": " +Size of the button.", + "name": "size", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": "medium", + }, + "type": Object { + "required": true, + "summary": "ButtonSize", + }, + }, + "type": Object { + "name": "object", + }, + }, + "someDataObject": Object { + "defaultValue": undefined, + "description": " +Specifies some arbitrary object", + "name": "someDataObject", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "ISomeInterface", + }, + }, + "type": Object { + "name": "object", + }, + }, + "somethingYouShouldNotUse": Object { + "defaultValue": false, + "description": " + +Some input you shouldn't use. + +", + "name": "somethingYouShouldNotUse", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": false, + }, + "type": Object { + "required": true, + "summary": "boolean", + }, + }, + "type": Object { + "name": "boolean", + }, + }, +} +`; diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-posix.snapshot b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-posix.snapshot new file mode 100644 index 000000000000..de95727d81f5 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-posix.snapshot @@ -0,0 +1,1326 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`angular component properties doc-button 1`] = ` +Object { + "classes": Array [], + "components": Array [ + Object { + "accessors": Object { + "inputValue": Object { + "getSignature": Object { + "description": "

Getter for inputValue.

+", + "line": 116, + "name": "inputValue", + "rawdescription": " +Getter for \`inputValue\`.", + "returnType": "", + "type": "", + }, + "name": "inputValue", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "type": "string", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Setter for inputValue that is also an @Input.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "tagName": Object { + "text": "param", + }, + "type": "string", + }, + ], + "line": 111, + "name": "inputValue", + "rawdescription": " +Setter for \`inputValue\` that is also an \`@Input\`.", + "returnType": "void", + "type": "void", + }, + }, + "item": Object { + "name": "item", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "item", + "type": "T[]", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "item", + "tagName": Object { + "text": "param", + }, + "type": "T[]", + }, + ], + "line": 196, + "name": "item", + "returnType": "void", + "type": "void", + }, + }, + "value": Object { + "getSignature": Object { + "description": "

Get the private value.

+", + "line": 155, + "name": "value", + "rawdescription": " +Get the private value.", + "returnType": "string | number", + "type": "", + }, + "name": "value", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "type": "string | number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Set the private value.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "tagName": Object { + "text": "param", + }, + "type": "string | number", + }, + ], + "line": 150, + "name": "value", + "rawdescription": " +Set the private value.", + "returnType": "void", + "type": "void", + }, + }, + }, + "assetsDirs": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular.

+

It supports markdown, so you can embed formatted text, +like bold, italic, and inline code.

+
+

How you like dem apples?! It's never been easier to document all your components.

+
+", + "encapsulation": Array [], + "entryComponents": Array [], + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "hostBindings": Array [ + Object { + "decorators": Array [], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "line": 125, + "name": "class.focused", + "type": "boolean", + }, + ], + "hostListeners": Array [ + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "type": "", + }, + ], + "argsDecorator": Array [ + "$event.target", + ], + "deprecated": false, + "deprecationMessage": "", + "line": 121, + "name": "click", + }, + ], + "id": "component-InputComponent-d145da25329b094ee29610c45a9e46387cb39eddb2a67b4c9fadb84bcec76eacd60d131e48d98b2ee5725dedd25f2eb299b704e8e0a34307d6e84f6e57d57044", + "inputs": Array [], + "inputsClass": Array [ + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Specify the accent-type of the button

+", + "line": 57, + "name": "accent", + "rawdescription": " +Specify the accent-type of the button", + "type": "ButtonAccent", + }, + Object { + "decorators": Array [], + "defaultValue": "'secondary'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Appearance style of the button.

+", + "line": 53, + "name": "appearance", + "rawdescription": " +Appearance style of the button.", + "type": "\\"primary\\" | \\"secondary\\"", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Setter for inputValue that is also an @Input.

+", + "line": 111, + "name": "inputValue", + "rawdescription": " +Setter for \`inputValue\` that is also an \`@Input\`.", + "type": "string", + }, + Object { + "decorators": Array [], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "description": "

Sets the button to a disabled state.

+", + "line": 61, + "name": "isDisabled", + "rawdescription": " +Sets the button to a disabled state.", + "type": "boolean", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "line": 196, + "name": "item", + "type": "T[]", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

The inner text of the button.

+", + "jsdoctags": Array [ + Object { + "comment": "", + "end": 1587, + "flags": 4227072, + "kind": 325, + "modifierFlagsCache": 0, + "pos": 1574, + "tagName": Object { + "end": 1583, + "escapedText": "required", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 1575, + "transformFlags": 0, + }, + "transformFlags": 0, + }, + ], + "line": 69, + "name": "label", + "rawdescription": " + +The inner text of the button. + +", + "type": "string", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "line": 193, + "name": "showKeyAlias", + "type": "", + }, + Object { + "decorators": Array [], + "defaultValue": "'medium'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Size of the button.

+", + "line": 73, + "name": "size", + "rawdescription": " +Size of the button.", + "type": "ButtonSize", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Specifies some arbitrary object

+", + "line": 76, + "name": "someDataObject", + "rawdescription": " +Specifies some arbitrary object", + "type": "ISomeInterface", + }, + Object { + "decorators": Array [], + "defaultValue": "false", + "deprecated": true, + "deprecationMessage": "", + "description": "

Some input you shouldn't use.

+", + "jsdoctags": Array [ + Object { + "comment": "", + "end": 1864, + "flags": 4227072, + "kind": 329, + "modifierFlagsCache": 0, + "pos": 1849, + "tagName": Object { + "end": 1860, + "escapedText": "deprecated", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 1850, + "transformFlags": 0, + }, + "transformFlags": 0, + }, + ], + "line": 84, + "name": "somethingYouShouldNotUse", + "rawdescription": " + +Some input you shouldn't use. + +", + "type": "boolean", + }, + ], + "methodsClass": Array [ + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "x", + "type": "number", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "y", + "type": "string | number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

An internal calculation method which adds x and y together.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some number you'd like to use.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 3580, + "escapedText": "x", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3579, + "transformFlags": 0, + }, + "tagName": Object { + "end": 3578, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3573, + "transformFlags": 0, + }, + "type": "number", + }, + Object { + "comment": "

Some other number or string you'd like to use, will have parseInt() applied before calculation.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 3625, + "escapedText": "y", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3624, + "transformFlags": 0, + }, + "tagName": Object { + "end": 3623, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3618, + "transformFlags": 0, + }, + "type": "string | number", + }, + ], + "line": 165, + "modifierKind": Array [ + 123, + ], + "name": "calc", + "optional": false, + "rawdescription": " + +An internal calculation method which adds \`x\` and \`y\` together. + +", + "returnType": "number", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "type": "", + }, + ], + "decorators": Array [ + Object { + "name": "HostListener", + "stringifiedArguments": "'click', ['$event.target']", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "tagName": Object { + "text": "param", + }, + "type": "", + }, + ], + "line": 121, + "name": "onClickListener", + "optional": false, + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "password", + "type": "string", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A private method.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some password.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 4141, + "escapedText": "password", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 4133, + "transformFlags": 0, + }, + "tagName": Object { + "end": 4132, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 4127, + "transformFlags": 0, + }, + "type": "string", + }, + ], + "line": 188, + "modifierKind": Array [ + 121, + ], + "name": "privateMethod", + "optional": false, + "rawdescription": " + +A private method. + +", + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "id", + "optional": true, + "type": "number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A protected method.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some id.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 4000, + "escapedText": "id", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3998, + "transformFlags": 0, + }, + "optional": true, + "tagName": Object { + "end": 3997, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3992, + "transformFlags": 0, + }, + "type": "number", + }, + ], + "line": 179, + "modifierKind": Array [ + 122, + ], + "name": "protectedMethod", + "optional": false, + "rawdescription": " + +A protected method. + +", + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "things", + "type": "ISomeInterface", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A public method using an interface.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "things", + "tagName": Object { + "text": "param", + }, + "type": "ISomeInterface", + }, + ], + "line": 170, + "modifierKind": Array [ + 123, + ], + "name": "publicMethod", + "optional": false, + "rawdescription": " +A public method using an interface.", + "returnType": "void", + "typeParameters": Array [], + }, + ], + "name": "InputComponent", + "outputs": Array [], + "outputsClass": Array [ + Object { + "defaultValue": "new EventEmitter()", + "deprecated": false, + "deprecationMessage": "", + "description": "

Handler to be called when the button is clicked by a user.

+

Will also block the emission of the event if isDisabled is true.

+", + "line": 92, + "name": "onClick", + "rawdescription": " + +Handler to be called when the button is clicked by a user. + +Will also block the emission of the event if \`isDisabled\` is true. +", + "type": "EventEmitter", + }, + ], + "propertiesClass": Array [ + Object { + "defaultValue": "'some value'", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 107, + "modifierKind": Array [ + 121, + ], + "name": "_inputValue", + "optional": false, + "type": "string", + }, + Object { + "defaultValue": "'Private hello'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Private value.

+", + "line": 147, + "modifierKind": Array [ + 121, + ], + "name": "_value", + "optional": false, + "rawdescription": " +Private value.", + "type": "string", + }, + Object { + "decorators": Array [ + Object { + "name": "ViewChild", + "stringifiedArguments": "'buttonRef', {static: false}", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 49, + "name": "buttonRef", + "optional": false, + "type": "ElementRef", + }, + Object { + "decorators": Array [ + Object { + "name": "HostBinding", + "stringifiedArguments": "'class.focused'", + }, + ], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 125, + "name": "focus", + "optional": false, + "type": "", + }, + Object { + "defaultValue": "'Public hello'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Public value.

+", + "line": 144, + "modifierKind": Array [ + 123, + ], + "name": "internalProperty", + "optional": false, + "rawdescription": " +Public value.", + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 200, + "modifierKind": Array [ + 123, + ], + "name": "processedItem", + "optional": false, + "type": "T[]", + }, + ], + "providers": Array [], + "rawdescription": " + +This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + +It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, +like **bold**, _italic_, and \`inline code\`. + +> How you like dem apples?! It's never been easier to document all your components. + +", + "selector": "doc-button", + "sourceCode": "import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and \`inline code\`. + * + * > How you like dem apples?! It's never been easier to document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code \`ThingThing\` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if \`isDisabled\` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the \`ignore\` annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for \`inputValue\` that is also an \`@Input\`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for \`inputValue\`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => \`btn-\${_class}\`); + } + + /** + * @ignore + */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = \`\${value}\`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds \`x\` and \`y\` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have \`parseInt()\` applied before calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(\`\${y}\`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some \`id\`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some \`password\`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} +", + "styleUrls": Array [], + "styleUrlsData": "", + "styles": Array [], + "stylesData": "", + "template": "", + "templateUrl": Array [], + "type": "component", + "viewProviders": Array [], + }, + ], + "coverage": Object { + "count": 21, + "files": Array [ + Object { + "coverageCount": "16/25", + "coveragePercent": 64, + "filePath": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "linktype": "component", + "name": "InputComponent", + "status": "good", + "type": "component", + }, + Object { + "coverageCount": "0/4", + "coveragePercent": 0, + "filePath": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "linktype": "interface", + "name": "ISomeInterface", + "status": "low", + "type": "interface", + }, + Object { + "coverageCount": "0/1", + "coveragePercent": 0, + "filePath": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "linksubtype": "variable", + "linktype": "miscellaneous", + "name": "exportedConstant", + "status": "low", + "type": "variable", + }, + ], + "status": "low", + }, + "directives": Array [], + "guards": Array [], + "injectables": Array [], + "interceptors": Array [], + "interfaces": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "id": "interface-ISomeInterface-d145da25329b094ee29610c45a9e46387cb39eddb2a67b4c9fadb84bcec76eacd60d131e48d98b2ee5725dedd25f2eb299b704e8e0a34307d6e84f6e57d57044", + "indexSignatures": Array [], + "kind": 165, + "methods": Array [], + "name": "ISomeInterface", + "properties": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 26, + "name": "one", + "optional": false, + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 28, + "name": "three", + "optional": false, + "type": "any[]", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 27, + "name": "two", + "optional": false, + "type": "boolean", + }, + ], + "sourceCode": "import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and \`inline code\`. + * + * > How you like dem apples?! It's never been easier to document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code \`ThingThing\` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if \`isDisabled\` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the \`ignore\` annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for \`inputValue\` that is also an \`@Input\`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for \`inputValue\`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => \`btn-\${_class}\`); + } + + /** + * @ignore + */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = \`\${value}\`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds \`x\` and \`y\` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have \`parseInt()\` applied before calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(\`\${y}\`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some \`id\`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some \`password\`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} +", + "type": "interface", + }, + ], + "miscellaneous": Object { + "enumerations": Array [ + Object { + "childs": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "Normal", + "value": "Normal", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "High", + "value": "High", + }, + ], + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "ButtonAccent", + "subtype": "enum", + }, + ], + "functions": Array [], + "groupedEnumerations": Object { + "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts": Array [ + Object { + "childs": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "Normal", + "value": "Normal", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "High", + "value": "High", + }, + ], + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "ButtonAccent", + "subtype": "enum", + }, + ], + }, + "groupedFunctions": Object {}, + "groupedTypeAliases": Object { + "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts": Array [ + Object { + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "kind": 186, + "name": "ButtonSize", + "rawtype": "\\"small\\" | \\"medium\\" | \\"large\\" | \\"xlarge\\"", + "subtype": "typealias", + }, + ], + }, + "groupedVariables": Object { + "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts": Array [ + Object { + "ctype": "miscellaneous", + "defaultValue": "'An exported constant'", + "deprecated": false, + "deprecationMessage": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "exportedConstant", + "subtype": "variable", + "type": "string", + }, + ], + }, + "typealiases": Array [ + Object { + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "kind": 186, + "name": "ButtonSize", + "rawtype": "\\"small\\" | \\"medium\\" | \\"large\\" | \\"xlarge\\"", + "subtype": "typealias", + }, + ], + "variables": Array [ + Object { + "ctype": "miscellaneous", + "defaultValue": "'An exported constant'", + "deprecated": false, + "deprecationMessage": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "exportedConstant", + "subtype": "variable", + "type": "string", + }, + ], + }, + "modules": Array [], + "pipes": Array [], + "routes": Array [], +} +`; diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-undefined.snapshot b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-undefined.snapshot new file mode 100644 index 000000000000..de95727d81f5 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-undefined.snapshot @@ -0,0 +1,1326 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`angular component properties doc-button 1`] = ` +Object { + "classes": Array [], + "components": Array [ + Object { + "accessors": Object { + "inputValue": Object { + "getSignature": Object { + "description": "

Getter for inputValue.

+", + "line": 116, + "name": "inputValue", + "rawdescription": " +Getter for \`inputValue\`.", + "returnType": "", + "type": "", + }, + "name": "inputValue", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "type": "string", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Setter for inputValue that is also an @Input.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "tagName": Object { + "text": "param", + }, + "type": "string", + }, + ], + "line": 111, + "name": "inputValue", + "rawdescription": " +Setter for \`inputValue\` that is also an \`@Input\`.", + "returnType": "void", + "type": "void", + }, + }, + "item": Object { + "name": "item", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "item", + "type": "T[]", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "item", + "tagName": Object { + "text": "param", + }, + "type": "T[]", + }, + ], + "line": 196, + "name": "item", + "returnType": "void", + "type": "void", + }, + }, + "value": Object { + "getSignature": Object { + "description": "

Get the private value.

+", + "line": 155, + "name": "value", + "rawdescription": " +Get the private value.", + "returnType": "string | number", + "type": "", + }, + "name": "value", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "type": "string | number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Set the private value.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "tagName": Object { + "text": "param", + }, + "type": "string | number", + }, + ], + "line": 150, + "name": "value", + "rawdescription": " +Set the private value.", + "returnType": "void", + "type": "void", + }, + }, + }, + "assetsDirs": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular.

+

It supports markdown, so you can embed formatted text, +like bold, italic, and inline code.

+
+

How you like dem apples?! It's never been easier to document all your components.

+
+", + "encapsulation": Array [], + "entryComponents": Array [], + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "hostBindings": Array [ + Object { + "decorators": Array [], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "line": 125, + "name": "class.focused", + "type": "boolean", + }, + ], + "hostListeners": Array [ + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "type": "", + }, + ], + "argsDecorator": Array [ + "$event.target", + ], + "deprecated": false, + "deprecationMessage": "", + "line": 121, + "name": "click", + }, + ], + "id": "component-InputComponent-d145da25329b094ee29610c45a9e46387cb39eddb2a67b4c9fadb84bcec76eacd60d131e48d98b2ee5725dedd25f2eb299b704e8e0a34307d6e84f6e57d57044", + "inputs": Array [], + "inputsClass": Array [ + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Specify the accent-type of the button

+", + "line": 57, + "name": "accent", + "rawdescription": " +Specify the accent-type of the button", + "type": "ButtonAccent", + }, + Object { + "decorators": Array [], + "defaultValue": "'secondary'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Appearance style of the button.

+", + "line": 53, + "name": "appearance", + "rawdescription": " +Appearance style of the button.", + "type": "\\"primary\\" | \\"secondary\\"", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Setter for inputValue that is also an @Input.

+", + "line": 111, + "name": "inputValue", + "rawdescription": " +Setter for \`inputValue\` that is also an \`@Input\`.", + "type": "string", + }, + Object { + "decorators": Array [], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "description": "

Sets the button to a disabled state.

+", + "line": 61, + "name": "isDisabled", + "rawdescription": " +Sets the button to a disabled state.", + "type": "boolean", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "line": 196, + "name": "item", + "type": "T[]", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

The inner text of the button.

+", + "jsdoctags": Array [ + Object { + "comment": "", + "end": 1587, + "flags": 4227072, + "kind": 325, + "modifierFlagsCache": 0, + "pos": 1574, + "tagName": Object { + "end": 1583, + "escapedText": "required", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 1575, + "transformFlags": 0, + }, + "transformFlags": 0, + }, + ], + "line": 69, + "name": "label", + "rawdescription": " + +The inner text of the button. + +", + "type": "string", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "line": 193, + "name": "showKeyAlias", + "type": "", + }, + Object { + "decorators": Array [], + "defaultValue": "'medium'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Size of the button.

+", + "line": 73, + "name": "size", + "rawdescription": " +Size of the button.", + "type": "ButtonSize", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Specifies some arbitrary object

+", + "line": 76, + "name": "someDataObject", + "rawdescription": " +Specifies some arbitrary object", + "type": "ISomeInterface", + }, + Object { + "decorators": Array [], + "defaultValue": "false", + "deprecated": true, + "deprecationMessage": "", + "description": "

Some input you shouldn't use.

+", + "jsdoctags": Array [ + Object { + "comment": "", + "end": 1864, + "flags": 4227072, + "kind": 329, + "modifierFlagsCache": 0, + "pos": 1849, + "tagName": Object { + "end": 1860, + "escapedText": "deprecated", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 1850, + "transformFlags": 0, + }, + "transformFlags": 0, + }, + ], + "line": 84, + "name": "somethingYouShouldNotUse", + "rawdescription": " + +Some input you shouldn't use. + +", + "type": "boolean", + }, + ], + "methodsClass": Array [ + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "x", + "type": "number", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "y", + "type": "string | number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

An internal calculation method which adds x and y together.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some number you'd like to use.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 3580, + "escapedText": "x", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3579, + "transformFlags": 0, + }, + "tagName": Object { + "end": 3578, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3573, + "transformFlags": 0, + }, + "type": "number", + }, + Object { + "comment": "

Some other number or string you'd like to use, will have parseInt() applied before calculation.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 3625, + "escapedText": "y", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3624, + "transformFlags": 0, + }, + "tagName": Object { + "end": 3623, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3618, + "transformFlags": 0, + }, + "type": "string | number", + }, + ], + "line": 165, + "modifierKind": Array [ + 123, + ], + "name": "calc", + "optional": false, + "rawdescription": " + +An internal calculation method which adds \`x\` and \`y\` together. + +", + "returnType": "number", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "type": "", + }, + ], + "decorators": Array [ + Object { + "name": "HostListener", + "stringifiedArguments": "'click', ['$event.target']", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "tagName": Object { + "text": "param", + }, + "type": "", + }, + ], + "line": 121, + "name": "onClickListener", + "optional": false, + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "password", + "type": "string", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A private method.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some password.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 4141, + "escapedText": "password", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 4133, + "transformFlags": 0, + }, + "tagName": Object { + "end": 4132, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 4127, + "transformFlags": 0, + }, + "type": "string", + }, + ], + "line": 188, + "modifierKind": Array [ + 121, + ], + "name": "privateMethod", + "optional": false, + "rawdescription": " + +A private method. + +", + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "id", + "optional": true, + "type": "number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A protected method.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some id.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 4000, + "escapedText": "id", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3998, + "transformFlags": 0, + }, + "optional": true, + "tagName": Object { + "end": 3997, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3992, + "transformFlags": 0, + }, + "type": "number", + }, + ], + "line": 179, + "modifierKind": Array [ + 122, + ], + "name": "protectedMethod", + "optional": false, + "rawdescription": " + +A protected method. + +", + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "things", + "type": "ISomeInterface", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A public method using an interface.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "things", + "tagName": Object { + "text": "param", + }, + "type": "ISomeInterface", + }, + ], + "line": 170, + "modifierKind": Array [ + 123, + ], + "name": "publicMethod", + "optional": false, + "rawdescription": " +A public method using an interface.", + "returnType": "void", + "typeParameters": Array [], + }, + ], + "name": "InputComponent", + "outputs": Array [], + "outputsClass": Array [ + Object { + "defaultValue": "new EventEmitter()", + "deprecated": false, + "deprecationMessage": "", + "description": "

Handler to be called when the button is clicked by a user.

+

Will also block the emission of the event if isDisabled is true.

+", + "line": 92, + "name": "onClick", + "rawdescription": " + +Handler to be called when the button is clicked by a user. + +Will also block the emission of the event if \`isDisabled\` is true. +", + "type": "EventEmitter", + }, + ], + "propertiesClass": Array [ + Object { + "defaultValue": "'some value'", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 107, + "modifierKind": Array [ + 121, + ], + "name": "_inputValue", + "optional": false, + "type": "string", + }, + Object { + "defaultValue": "'Private hello'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Private value.

+", + "line": 147, + "modifierKind": Array [ + 121, + ], + "name": "_value", + "optional": false, + "rawdescription": " +Private value.", + "type": "string", + }, + Object { + "decorators": Array [ + Object { + "name": "ViewChild", + "stringifiedArguments": "'buttonRef', {static: false}", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 49, + "name": "buttonRef", + "optional": false, + "type": "ElementRef", + }, + Object { + "decorators": Array [ + Object { + "name": "HostBinding", + "stringifiedArguments": "'class.focused'", + }, + ], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 125, + "name": "focus", + "optional": false, + "type": "", + }, + Object { + "defaultValue": "'Public hello'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Public value.

+", + "line": 144, + "modifierKind": Array [ + 123, + ], + "name": "internalProperty", + "optional": false, + "rawdescription": " +Public value.", + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 200, + "modifierKind": Array [ + 123, + ], + "name": "processedItem", + "optional": false, + "type": "T[]", + }, + ], + "providers": Array [], + "rawdescription": " + +This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + +It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, +like **bold**, _italic_, and \`inline code\`. + +> How you like dem apples?! It's never been easier to document all your components. + +", + "selector": "doc-button", + "sourceCode": "import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and \`inline code\`. + * + * > How you like dem apples?! It's never been easier to document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code \`ThingThing\` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if \`isDisabled\` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the \`ignore\` annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for \`inputValue\` that is also an \`@Input\`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for \`inputValue\`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => \`btn-\${_class}\`); + } + + /** + * @ignore + */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = \`\${value}\`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds \`x\` and \`y\` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have \`parseInt()\` applied before calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(\`\${y}\`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some \`id\`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some \`password\`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} +", + "styleUrls": Array [], + "styleUrlsData": "", + "styles": Array [], + "stylesData": "", + "template": "", + "templateUrl": Array [], + "type": "component", + "viewProviders": Array [], + }, + ], + "coverage": Object { + "count": 21, + "files": Array [ + Object { + "coverageCount": "16/25", + "coveragePercent": 64, + "filePath": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "linktype": "component", + "name": "InputComponent", + "status": "good", + "type": "component", + }, + Object { + "coverageCount": "0/4", + "coveragePercent": 0, + "filePath": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "linktype": "interface", + "name": "ISomeInterface", + "status": "low", + "type": "interface", + }, + Object { + "coverageCount": "0/1", + "coveragePercent": 0, + "filePath": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "linksubtype": "variable", + "linktype": "miscellaneous", + "name": "exportedConstant", + "status": "low", + "type": "variable", + }, + ], + "status": "low", + }, + "directives": Array [], + "guards": Array [], + "injectables": Array [], + "interceptors": Array [], + "interfaces": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "id": "interface-ISomeInterface-d145da25329b094ee29610c45a9e46387cb39eddb2a67b4c9fadb84bcec76eacd60d131e48d98b2ee5725dedd25f2eb299b704e8e0a34307d6e84f6e57d57044", + "indexSignatures": Array [], + "kind": 165, + "methods": Array [], + "name": "ISomeInterface", + "properties": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 26, + "name": "one", + "optional": false, + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 28, + "name": "three", + "optional": false, + "type": "any[]", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 27, + "name": "two", + "optional": false, + "type": "boolean", + }, + ], + "sourceCode": "import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and \`inline code\`. + * + * > How you like dem apples?! It's never been easier to document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code \`ThingThing\` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if \`isDisabled\` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the \`ignore\` annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for \`inputValue\` that is also an \`@Input\`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for \`inputValue\`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => \`btn-\${_class}\`); + } + + /** + * @ignore + */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = \`\${value}\`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds \`x\` and \`y\` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have \`parseInt()\` applied before calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(\`\${y}\`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some \`id\`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some \`password\`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} +", + "type": "interface", + }, + ], + "miscellaneous": Object { + "enumerations": Array [ + Object { + "childs": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "Normal", + "value": "Normal", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "High", + "value": "High", + }, + ], + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "ButtonAccent", + "subtype": "enum", + }, + ], + "functions": Array [], + "groupedEnumerations": Object { + "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts": Array [ + Object { + "childs": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "Normal", + "value": "Normal", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "High", + "value": "High", + }, + ], + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "ButtonAccent", + "subtype": "enum", + }, + ], + }, + "groupedFunctions": Object {}, + "groupedTypeAliases": Object { + "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts": Array [ + Object { + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "kind": 186, + "name": "ButtonSize", + "rawtype": "\\"small\\" | \\"medium\\" | \\"large\\" | \\"xlarge\\"", + "subtype": "typealias", + }, + ], + }, + "groupedVariables": Object { + "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts": Array [ + Object { + "ctype": "miscellaneous", + "defaultValue": "'An exported constant'", + "deprecated": false, + "deprecationMessage": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "exportedConstant", + "subtype": "variable", + "type": "string", + }, + ], + }, + "typealiases": Array [ + Object { + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "kind": 186, + "name": "ButtonSize", + "rawtype": "\\"small\\" | \\"medium\\" | \\"large\\" | \\"xlarge\\"", + "subtype": "typealias", + }, + ], + "variables": Array [ + Object { + "ctype": "miscellaneous", + "defaultValue": "'An exported constant'", + "deprecated": false, + "deprecationMessage": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "exportedConstant", + "subtype": "variable", + "type": "string", + }, + ], + }, + "modules": Array [], + "pipes": Array [], + "routes": Array [], +} +`; diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-windows.snapshot b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-windows.snapshot new file mode 100644 index 000000000000..87b561823850 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-windows.snapshot @@ -0,0 +1,1297 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`angular component properties doc-button 1`] = ` +Object { + "classes": Array [], + "components": Array [ + Object { + "accessors": Object { + "inputValue": Object { + "getSignature": Object { + "description": "

Getter for inputValue.

+", + "line": 115, + "name": "inputValue", + "rawdescription": "Getter for \`inputValue\`.", + "returnType": "", + "type": "", + }, + "name": "inputValue", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "type": "string", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Setter for inputValue that is also an @Input.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "tagName": Object { + "text": "param", + }, + "type": "string", + }, + ], + "line": 110, + "name": "inputValue", + "rawdescription": "Setter for \`inputValue\` that is also an \`@Input\`.", + "returnType": "void", + "type": "void", + }, + }, + "item": Object { + "name": "item", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "item", + "type": "T[]", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "item", + "tagName": Object { + "text": "param", + }, + "type": "T[]", + }, + ], + "line": 195, + "name": "item", + "returnType": "void", + "type": "void", + }, + }, + "value": Object { + "getSignature": Object { + "description": "

Get the private value.

+", + "line": 154, + "name": "value", + "rawdescription": "Get the private value.", + "returnType": "string | number", + "type": "", + }, + "name": "value", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "type": "string | number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Set the private value.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "tagName": Object { + "text": "param", + }, + "type": "string | number", + }, + ], + "line": 149, + "name": "value", + "rawdescription": "Set the private value.", + "returnType": "void", + "type": "void", + }, + }, + }, + "assetsDirs": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular.

+

It supports markdown, so you can embed formatted text, +like bold, italic, and inline code.

+
+

How you like dem apples?! It's never been easier to document all your components.

+
+", + "encapsulation": Array [], + "entryComponents": Array [], + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "hostBindings": Array [ + Object { + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "line": 124, + "name": "class.focused", + "type": "boolean", + }, + ], + "hostListeners": Array [ + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "type": "", + }, + ], + "argsDecorator": Array [ + "$event.target", + ], + "deprecated": false, + "deprecationMessage": "", + "line": 120, + "name": "click", + }, + ], + "id": "component-InputComponent-fd2eff3e4da750f1c06d4928670993b3", + "inputs": Array [], + "inputsClass": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "

Specify the accent-type of the button

+", + "line": 56, + "name": "accent", + "rawdescription": "Specify the accent-type of the button", + "type": "ButtonAccent", + }, + Object { + "defaultValue": "'secondary'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Appearance style of the button.

+", + "line": 52, + "name": "appearance", + "rawdescription": "Appearance style of the button.", + "type": "\\"primary\\" | \\"secondary\\"", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "

Setter for inputValue that is also an @Input.

+", + "line": 110, + "name": "inputValue", + "rawdescription": "Setter for \`inputValue\` that is also an \`@Input\`.", + "type": "string", + }, + Object { + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "description": "

Sets the button to a disabled state.

+", + "line": 60, + "name": "isDisabled", + "rawdescription": "Sets the button to a disabled state.", + "type": "boolean", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "line": 195, + "name": "item", + "type": "[]", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "

The inner text of the button.

+", + "jsdoctags": Array [ + Object { + "comment": "", + "end": 1590, + "flags": 4227072, + "kind": 317, + "modifierFlagsCache": 0, + "pos": 1576, + "tagName": Object { + "end": 1585, + "escapedText": "required", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 1577, + "transformFlags": 0, + }, + "transformFlags": 0, + }, + ], + "line": 68, + "name": "label", + "rawdescription": "The inner text of the button.", + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "line": 192, + "name": "showKeyAlias", + "type": "", + }, + Object { + "defaultValue": "'medium'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Size of the button.

+", + "line": 72, + "name": "size", + "rawdescription": "Size of the button.", + "type": "ButtonSize", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "

Specifies some arbitrary object

+", + "line": 75, + "name": "someDataObject", + "rawdescription": "Specifies some arbitrary object", + "type": "ISomeInterface", + }, + Object { + "defaultValue": "false", + "deprecated": true, + "deprecationMessage": "", + "description": "

Some input you shouldn't use.

+", + "jsdoctags": Array [ + Object { + "comment": "", + "end": 1882, + "flags": 4227072, + "kind": 321, + "modifierFlagsCache": 0, + "pos": 1866, + "tagName": Object { + "end": 1877, + "escapedText": "deprecated", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 1867, + "transformFlags": 0, + }, + "transformFlags": 0, + }, + ], + "line": 83, + "name": "somethingYouShouldNotUse", + "rawdescription": "Some input you shouldn't use.", + "type": "boolean", + }, + ], + "methodsClass": Array [ + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "x", + "type": "number", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "y", + "type": "string | number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

An internal calculation method which adds x and y together.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some number you'd like to use.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 3678, + "escapedText": "x", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 3677, + "transformFlags": 0, + }, + "tagName": Object { + "end": 3676, + "escapedText": "param", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 3671, + "transformFlags": 0, + }, + "type": "number", + }, + Object { + "comment": "

Some other number or string you'd like to use, will have parseInt() applied before calculation.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 3724, + "escapedText": "y", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 3723, + "transformFlags": 0, + }, + "tagName": Object { + "end": 3722, + "escapedText": "param", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 3717, + "transformFlags": 0, + }, + "type": "string | number", + }, + ], + "line": 164, + "modifierKind": Array [ + 122, + ], + "name": "calc", + "optional": false, + "rawdescription": " + +An internal calculation method which adds \`x\` and \`y\` together. + +", + "returnType": "number", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "type": "", + }, + ], + "decorators": Array [ + Object { + "name": "HostListener", + "stringifiedArguments": "'click', ['$event.target']", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "tagName": Object { + "text": "param", + }, + "type": "", + }, + ], + "line": 120, + "name": "onClickListener", + "optional": false, + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "password", + "type": "string", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A private method.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some password.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 4263, + "escapedText": "password", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 4255, + "transformFlags": 0, + }, + "tagName": Object { + "end": 4254, + "escapedText": "param", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 4249, + "transformFlags": 0, + }, + "type": "string", + }, + ], + "line": 187, + "modifierKind": Array [ + 120, + ], + "name": "privateMethod", + "optional": false, + "rawdescription": " + +A private method. + +", + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "id", + "optional": true, + "type": "number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A protected method.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some id.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 4113, + "escapedText": "id", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 4111, + "transformFlags": 0, + }, + "optional": true, + "tagName": Object { + "end": 4110, + "escapedText": "param", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 4105, + "transformFlags": 0, + }, + "type": "number", + }, + ], + "line": 178, + "modifierKind": Array [ + 121, + ], + "name": "protectedMethod", + "optional": false, + "rawdescription": " + +A protected method. + +", + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "things", + "type": "ISomeInterface", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A public method using an interface.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "things", + "tagName": Object { + "text": "param", + }, + "type": "ISomeInterface", + }, + ], + "line": 169, + "modifierKind": Array [ + 122, + ], + "name": "publicMethod", + "optional": false, + "rawdescription": " +A public method using an interface.", + "returnType": "void", + "typeParameters": Array [], + }, + ], + "name": "InputComponent", + "outputs": Array [], + "outputsClass": Array [ + Object { + "defaultValue": "new EventEmitter()", + "deprecated": false, + "deprecationMessage": "", + "description": "

Handler to be called when the button is clicked by a user.

+

Will also block the emission of the event if isDisabled is true.

+", + "line": 91, + "name": "onClick", + "rawdescription": " + +Handler to be called when the button is clicked by a user. + +Will also block the emission of the event if \`isDisabled\` is true. +", + "type": "EventEmitter", + }, + ], + "propertiesClass": Array [ + Object { + "defaultValue": "'some value'", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 106, + "modifierKind": Array [ + 120, + ], + "name": "_inputValue", + "optional": false, + "type": "string", + }, + Object { + "defaultValue": "'Private hello'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Private value.

+", + "line": 146, + "modifierKind": Array [ + 120, + ], + "name": "_value", + "optional": false, + "rawdescription": " +Private value.", + "type": "string", + }, + Object { + "decorators": Array [ + Object { + "name": "ViewChild", + "stringifiedArguments": "'buttonRef', {static: false}", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 48, + "name": "buttonRef", + "optional": false, + "type": "ElementRef", + }, + Object { + "decorators": Array [ + Object { + "name": "HostBinding", + "stringifiedArguments": "'class.focused'", + }, + ], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 124, + "name": "focus", + "optional": false, + "type": "", + }, + Object { + "defaultValue": "'Public hello'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Public value.

+", + "line": 143, + "modifierKind": Array [ + 122, + ], + "name": "internalProperty", + "optional": false, + "rawdescription": " +Public value.", + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 199, + "modifierKind": Array [ + 122, + ], + "name": "processedItem", + "optional": false, + "type": "T[]", + }, + ], + "providers": Array [], + "rawdescription": " + +This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + +It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, +like **bold**, _italic_, and \`inline code\`. + +> How you like dem apples?! It's never been easier to document all your components. + +", + "selector": "doc-button", + "sourceCode": "import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and \`inline code\`. + * + * > How you like dem apples?! It's never been easier to document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code \`ThingThing\` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if \`isDisabled\` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the \`ignore\` annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for \`inputValue\` that is also an \`@Input\`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for \`inputValue\`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => \`btn-\${_class}\`); + } + + /** + * @ignore + */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = \`\${value}\`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds \`x\` and \`y\` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have \`parseInt()\` applied before calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(\`\${y}\`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some \`id\`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some \`password\`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} +", + "styleUrls": Array [], + "styleUrlsData": "", + "styles": Array [], + "stylesData": "", + "template": "", + "templateUrl": Array [], + "type": "component", + "viewProviders": Array [], + }, + ], + "coverage": Object { + "count": 21, + "files": Array [ + Object { + "coverageCount": "16/25", + "coveragePercent": 64, + "filePath": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "linktype": "component", + "name": "InputComponent", + "status": "good", + "type": "component", + }, + Object { + "coverageCount": "0/4", + "coveragePercent": 0, + "filePath": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "linktype": "interface", + "name": "ISomeInterface", + "status": "low", + "type": "interface", + }, + Object { + "coverageCount": "0/1", + "coveragePercent": 0, + "filePath": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "linksubtype": "variable", + "linktype": "miscellaneous", + "name": "exportedConstant", + "status": "low", + "type": "variable", + }, + ], + "status": "low", + }, + "directives": Array [], + "guards": Array [], + "injectables": Array [], + "interceptors": Array [], + "interfaces": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "id": "interface-ISomeInterface-fd2eff3e4da750f1c06d4928670993b3", + "indexSignatures": Array [], + "kind": 163, + "methods": Array [], + "name": "ISomeInterface", + "properties": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 25, + "name": "one", + "optional": false, + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 27, + "name": "three", + "optional": false, + "type": "any[]", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 26, + "name": "two", + "optional": false, + "type": "boolean", + }, + ], + "sourceCode": "import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and \`inline code\`. + * + * > How you like dem apples?! It's never been easier to document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code \`ThingThing\` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if \`isDisabled\` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the \`ignore\` annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for \`inputValue\` that is also an \`@Input\`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for \`inputValue\`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => \`btn-\${_class}\`); + } + + /** + * @ignore + */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = \`\${value}\`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds \`x\` and \`y\` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have \`parseInt()\` applied before calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(\`\${y}\`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some \`id\`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some \`password\`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} +", + "type": "interface", + }, + ], + "miscellaneous": Object { + "enumerations": Array [ + Object { + "childs": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "Normal", + "value": "Normal", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "High", + "value": "High", + }, + ], + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "name": "ButtonAccent", + "subtype": "enum", + }, + ], + "functions": Array [], + "groupedEnumerations": Object { + "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts": Array [ + Object { + "childs": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "Normal", + "value": "Normal", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "High", + "value": "High", + }, + ], + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "name": "ButtonAccent", + "subtype": "enum", + }, + ], + }, + "groupedFunctions": Object {}, + "groupedTypeAliases": Object { + "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts": Array [ + Object { + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "kind": 183, + "name": "ButtonSize", + "rawtype": "\\"small\\" | \\"medium\\" | \\"large\\" | \\"xlarge\\"", + "subtype": "typealias", + }, + ], + }, + "groupedVariables": Object { + "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts": Array [ + Object { + "ctype": "miscellaneous", + "defaultValue": "'An exported constant'", + "deprecated": false, + "deprecationMessage": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "name": "exportedConstant", + "subtype": "variable", + "type": "string", + }, + ], + }, + "typealiases": Array [ + Object { + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "kind": 183, + "name": "ButtonSize", + "rawtype": "\\"small\\" | \\"medium\\" | \\"large\\" | \\"xlarge\\"", + "subtype": "typealias", + }, + ], + "variables": Array [ + Object { + "ctype": "miscellaneous", + "defaultValue": "'An exported constant'", + "deprecated": false, + "deprecationMessage": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "name": "exportedConstant", + "subtype": "variable", + "type": "string", + }, + ], + }, + "modules": Array [], + "pipes": Array [], + "routes": Array [], +} +`; diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/input.ts b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/input.ts new file mode 100644 index 000000000000..c1f3662499ae --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/input.ts @@ -0,0 +1,199 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck + +import type { ElementRef } from '@angular/core'; +import { + Component, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and `inline code`.> How you like dem apples?! It's never been easier to + * document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code `ThingThing` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if `isDisabled` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the `ignore` + * annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for `inputValue` that is also an `@Input`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for `inputValue`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => `btn-${_class}`); + } + + /** @ignore */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = `${value}`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds `x` and `y` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have `parseInt()` applied before + * calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(`${y}`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some `id`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some `password`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/properties.snapshot b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/properties.snapshot new file mode 100644 index 000000000000..efd774f746b2 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/properties.snapshot @@ -0,0 +1,230 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`angular component properties doc-button 1`] = ` +Object { + "sections": Object { + "inputs": Array [ + Object { + "defaultValue": Object { + "summary": "'secondary'", + }, + "description": "

Appearance style of the button.

+", + "name": "appearance", + "required": true, + "type": Object { + "summary": "\\"primary\\" | \\"secondary\\"", + }, + }, + Object { + "defaultValue": Object { + "summary": undefined, + }, + "description": "

Setter for inputValue that is also an @Input.

+", + "name": "inputValue", + "required": true, + "type": Object { + "summary": "string", + }, + }, + Object { + "defaultValue": Object { + "summary": "false", + }, + "description": "

Sets the button to a disabled state.

+", + "name": "isDisabled", + "required": true, + "type": Object { + "summary": undefined, + }, + }, + Object { + "defaultValue": Object { + "summary": undefined, + }, + "description": undefined, + "name": "item", + "required": true, + "type": Object { + "summary": "[]", + }, + }, + Object { + "defaultValue": Object { + "summary": undefined, + }, + "description": "

The inner text of the button.

+", + "name": "label", + "required": true, + "type": Object { + "summary": "string", + }, + }, + Object { + "defaultValue": Object { + "summary": undefined, + }, + "description": undefined, + "name": "showKeyAlias", + "required": true, + "type": Object { + "summary": "", + }, + }, + Object { + "defaultValue": Object { + "summary": "'medium'", + }, + "description": "

Size of the button.

+", + "name": "size", + "required": true, + "type": Object { + "summary": "ButtonSize", + }, + }, + Object { + "defaultValue": Object { + "summary": "false", + }, + "description": "

Some input you shouldn't use.

+", + "name": "somethingYouShouldNotUse", + "required": true, + "type": Object { + "summary": undefined, + }, + }, + ], + "methods": Array [ + Object { + "defaultValue": Object { + "summary": "", + }, + "description": "

An internal calculation method which adds x and y together.

+", + "name": "calc", + "required": false, + "type": Object { + "summary": "(x: number, y: string | number) => number", + }, + }, + Object { + "defaultValue": Object { + "summary": "", + }, + "description": "

A private method.

+", + "name": "privateMethod", + "required": false, + "type": Object { + "summary": "(password: string) => void", + }, + }, + Object { + "defaultValue": Object { + "summary": "", + }, + "description": "

A protected method.

+", + "name": "protectedMethod", + "required": false, + "type": Object { + "summary": "(id?: number) => void", + }, + }, + Object { + "defaultValue": Object { + "summary": "", + }, + "description": "

A public method using an interface.

+", + "name": "publicMethod", + "required": false, + "type": Object { + "summary": "(things: ISomeInterface) => void", + }, + }, + ], + "outputs": Array [ + Object { + "defaultValue": Object { + "summary": "new EventEmitter()", + }, + "description": "

Handler to be called when the button is clicked by a user.

+

Will also block the emission of the event if isDisabled is true.

+", + "name": "onClick", + "required": true, + "type": Object { + "summary": "EventEmitter", + }, + }, + ], + "properties": Array [ + Object { + "defaultValue": Object { + "summary": "'some value'", + }, + "description": "", + "name": "_inputValue", + "required": true, + "type": Object { + "summary": "string", + }, + }, + Object { + "defaultValue": Object { + "summary": "'Private hello'", + }, + "description": "

Private value.

+", + "name": "_value", + "required": true, + "type": Object { + "summary": "string", + }, + }, + Object { + "defaultValue": Object { + "summary": "'Public hello'", + }, + "description": "

Public value.

+", + "name": "internalProperty", + "required": true, + "type": Object { + "summary": "string", + }, + }, + Object { + "defaultValue": Object { + "summary": undefined, + }, + "description": "", + "name": "processedItem", + "required": true, + "type": Object { + "summary": "T[]", + }, + }, + ], + "view child": Array [ + Object { + "defaultValue": Object { + "summary": undefined, + }, + "description": "", + "name": "buttonRef", + "required": true, + "type": Object { + "summary": "ElementRef", + }, + }, + ], + }, +} +`; diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/tsconfig.json b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/tsconfig.json new file mode 100644 index 000000000000..ced6b7ae2f7c --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./*.ts"] +} diff --git a/code/frameworks/angular-vite/src/client/docs/angular-properties.test.ts b/code/frameworks/angular-vite/src/client/docs/angular-properties.test.ts new file mode 100644 index 000000000000..a4531cbbe480 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/angular-properties.test.ts @@ -0,0 +1,39 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +// File hierarchy: __testfixtures__ / some-test-case / input.* +const inputRegExp = /^input\..*$/; + +describe('angular component properties', () => { + const fixturesDir = join(__dirname, '__testfixtures__'); + readdirSync(fixturesDir, { withFileTypes: true }).forEach((testEntry) => { + if (testEntry.isDirectory()) { + const testDir = join(fixturesDir, testEntry.name); + const testFile = readdirSync(testDir).find((fileName) => inputRegExp.test(fileName)); + if (testFile) { + // TODO: Remove this as soon as the real test is fixed + it('true', () => { + expect(true).toEqual(true); + }); + // TODO: Fix this test + // it(`${testEntry.name}`, async () => { + // const inputPath = join(testDir, testFile); + + // // snapshot the output of compodoc + // const compodocOutput = runCompodoc(inputPath); + // const compodocJson = JSON.parse(compodocOutput); + // await expect(compodocJson).toMatchFileSnapshot( + // join(testDir, `compodoc-${SNAPSHOT_OS}.snapshot`) + // ); + + // // snapshot the output of addon-docs angular-properties + // const componentData = findComponentByName('InputComponent', compodocJson); + // const argTypes = extractArgTypesFromData(componentData); + // await expect(argTypes).toMatchFileSnapshot(join(testDir, 'argtypes.snapshot')); + // }); + } + } + }); +}); diff --git a/code/frameworks/angular-vite/src/client/docs/config.ts b/code/frameworks/angular-vite/src/client/docs/config.ts new file mode 100644 index 000000000000..e9e4c8e0578a --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/config.ts @@ -0,0 +1,15 @@ +import { SourceType } from 'storybook/internal/docs-tools'; +import type { DecoratorFunction, Parameters } from 'storybook/internal/types'; + +import { sourceDecorator } from './sourceDecorator'; + +export const parameters: Parameters = { + docs: { + source: { + type: SourceType.DYNAMIC, + language: 'html', + }, + }, +}; + +export const decorators: DecoratorFunction[] = [sourceDecorator]; diff --git a/code/frameworks/angular-vite/src/client/docs/sourceDecorator.ts b/code/frameworks/angular-vite/src/client/docs/sourceDecorator.ts new file mode 100644 index 000000000000..d5315f8dff99 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/sourceDecorator.ts @@ -0,0 +1,62 @@ +import { SourceType } from 'storybook/internal/docs-tools'; +import { useRef, emitTransformCode, useEffect } from 'storybook/preview-api'; +import type { ArgsStoryFn, PartialStoryFn } from 'storybook/internal/types'; + +import { computesTemplateSourceFromComponent } from '../../renderer'; +import type { AngularRenderer, StoryContext } from '../types'; + +export const skipSourceRender = (context: StoryContext) => { + const sourceParams = context?.parameters.docs?.source; + + // always render if the user forces it + if (sourceParams?.type === SourceType.DYNAMIC) { + return false; + } + // never render if the user is forcing the block to render code, or + // if the user provides code + return sourceParams?.code || sourceParams?.type === SourceType.CODE; +}; + +/** + * Angular source decorator. + * + * @param storyFn Fn + * @param context StoryContext + */ +export const sourceDecorator = ( + storyFn: PartialStoryFn, + context: StoryContext +) => { + const story = storyFn(); + const source = useRef(undefined); + + useEffect(() => { + if (skipSourceRender(context)) { + return; + } + + const { props, userDefinedTemplate } = story; + const { component, argTypes, parameters } = context; + const template: string = parameters.docs?.source?.excludeDecorators + ? (context.originalStoryFn as ArgsStoryFn)(context.args, context).template + : story.template; + + if (component && !userDefinedTemplate) { + const sourceFromComponent = computesTemplateSourceFromComponent(component, props, argTypes); + + // We might have a story with a Directive or Service defined as the component + // In these cases there might exist a template, even if we aren't able to create source from component + const newSource = sourceFromComponent || template; + + if (newSource && newSource !== source.current) { + emitTransformCode(newSource, context); + source.current = newSource; + } + } else if (template && template !== source.current) { + emitTransformCode(template, context); + source.current = template; + } + }); + + return story; +}; diff --git a/code/frameworks/angular-vite/src/client/globals.ts b/code/frameworks/angular-vite/src/client/globals.ts new file mode 100644 index 000000000000..6d3159bff7fa --- /dev/null +++ b/code/frameworks/angular-vite/src/client/globals.ts @@ -0,0 +1,37 @@ +import { global } from '@storybook/global'; + +/** + * This file includes polyfills needed by Angular and is loaded before the app. You can add your own + * extra polyfills to this file. + * + * This file is divided into 2 sections: + * + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), + * Edge> = 13 on the desktop, and iOS 10 and Chrome on mobile. + * + * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html + */ + +/** + * Required to support Web Animations `@angular/animation`. Needed for: All but Chrome, Firefox and + * Opera. http://caniuse.com/#feat=web-animation + */ +// import 'web-animations-js'; // Run `npm install --save web-animations-js`. + +// Included with Angular CLI. + +/** APPLICATION IMPORTS */ + +/** + * Date, currency, decimal and percent pipes. Needed for: All but Chrome, Firefox, Edge, IE11 and + * Safari 10 + */ +// import 'intl'; // Run `npm install --save intl`. +/** Need to import at least one locale-data with intl. */ +// import 'intl/locale-data/jsonp/en'; + +global.STORYBOOK_ENV = 'angular'; diff --git a/code/frameworks/angular-vite/src/client/index.ts b/code/frameworks/angular-vite/src/client/index.ts new file mode 100644 index 000000000000..0fa5b5c1ad07 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/index.ts @@ -0,0 +1,10 @@ +import './globals'; + +export * from './public-types'; +export * from './portable-stories'; +export * from './preview'; + +export type { StoryFnAngularReturnType as IStory } from './types'; + +export { moduleMetadata, componentWrapperDecorator, applicationConfig } from './decorators'; +export { argsToTemplate } from './argsToTemplate'; diff --git a/code/frameworks/angular-vite/src/client/portable-stories.ts b/code/frameworks/angular-vite/src/client/portable-stories.ts new file mode 100644 index 000000000000..b436ebf01987 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/portable-stories.ts @@ -0,0 +1,42 @@ +import { + setProjectAnnotations as originalSetProjectAnnotations, + setDefaultProjectAnnotations, +} from 'storybook/preview-api'; +import type { + NamedOrDefaultProjectAnnotations, + NormalizedProjectAnnotations, +} from 'storybook/internal/types'; + +import * as INTERNAL_DEFAULT_PROJECT_ANNOTATIONS from './render'; +import type { AngularRenderer } from './types'; + +/** + * Function that sets the globalConfig of your storybook. The global config is the preview module of + * your .storybook folder. + * + * It should be run a single time, so that your global config (e.g. decorators) is applied to your + * stories when using `composeStories` or `composeStory`. + * + * Example: + * + * ```jsx + * // setup-file.js + * import { setProjectAnnotations } from '@storybook/angular'; + * + * import projectAnnotations from './.storybook/preview'; + * + * setProjectAnnotations(projectAnnotations); + * ``` + * + * @param projectAnnotations - E.g. (import projectAnnotations from '../.storybook/preview') + */ +export function setProjectAnnotations( + projectAnnotations: + | NamedOrDefaultProjectAnnotations + | NamedOrDefaultProjectAnnotations[] +): NormalizedProjectAnnotations { + setDefaultProjectAnnotations(INTERNAL_DEFAULT_PROJECT_ANNOTATIONS); + return originalSetProjectAnnotations( + projectAnnotations + ) as NormalizedProjectAnnotations; +} diff --git a/code/frameworks/angular-vite/src/client/preview-prod.ts b/code/frameworks/angular-vite/src/client/preview-prod.ts new file mode 100644 index 000000000000..13a257400434 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/preview-prod.ts @@ -0,0 +1,3 @@ +import { enableProdMode } from '@angular/core'; + +enableProdMode(); diff --git a/code/frameworks/angular-vite/src/client/preview.ts b/code/frameworks/angular-vite/src/client/preview.ts new file mode 100644 index 000000000000..128d8bdc5b4c --- /dev/null +++ b/code/frameworks/angular-vite/src/client/preview.ts @@ -0,0 +1,257 @@ +import type { + AddonTypes, + InferTypes, + Meta, + Preview, + PreviewAddon, + Story, +} from 'storybook/internal/csf'; +import { definePreview as definePreviewBase } from 'storybook/internal/csf'; +import type { + ArgsStoryFn, + ComponentAnnotations, + DecoratorFunction, + ProjectAnnotations, + Renderer, + StoryAnnotations, +} from 'storybook/internal/types'; + +import type { RemoveIndexSignature, SetOptional, Simplify, UnionToIntersection } from 'type-fest'; + +import * as angularAnnotations from './config'; +import * as angularDocsAnnotations from './docs/config'; +import type { TransformComponentType } from './public-types'; +import { type AngularRenderer } from './types'; + +/** + * Creates an Angular-specific preview configuration with CSF factories support. + * + * This function wraps the base `definePreview` and adds Angular-specific annotations for rendering + * and documentation. It returns an `AngularPreview` that provides type-safe `meta()` and `story()` + * factory methods. + * + * @example + * + * ```ts + * // .storybook/preview.ts + * import { definePreview } from '@storybook/angular'; + * + * export const preview = definePreview({ + * addons: [], + * parameters: { layout: 'centered' }, + * }); + * ``` + */ +export function __definePreview[]>( + input: { addons: Addons } & ProjectAnnotations> +): AngularPreview> { + const preview = definePreviewBase({ + ...input, + addons: [angularAnnotations, angularDocsAnnotations, ...(input.addons ?? [])], + }) as unknown as AngularPreview>; + + return preview; +} + +type InferArgs = Simplify< + TArgs & Simplify>> +>; + +type InferComponentArgs any> = Partial< + TransformComponentType> +>; + +type InferAngularTypes = AngularRenderer & + T & { args: Simplify> }; + +/** + * Angular-specific Preview interface that provides type-safe CSF factory methods. + * + * Use `preview.meta()` to create a meta configuration for a component, and then `meta.story()` to + * create individual stories. The type system will infer args from the component, decorators, and + * any addon types. + * + * @example + * + * ```ts + * const meta = preview.meta({ component: ButtonComponent }); + * export const Primary = meta.story({ args: { label: 'Click me' } }); + * ``` + */ +export interface AngularPreview extends Preview { + /** + * Narrows the type of the preview to include additional type information. This is useful when you + * need to add args that aren't inferred from the component. + * + * @example + * + * ```ts + * const meta = preview.type<{ args: { theme: 'light' | 'dark' } }>().meta({ + * component: ButtonComponent, + * }); + * ``` + */ + type(): AngularPreview; + + meta< + C extends abstract new (...args: any) => any, + Decorators extends DecoratorFunction, + // Try to make Exact, TMetaArgs> work + TMetaArgs extends Partial & T['args']>, + >( + meta: { + component?: C; + args?: TMetaArgs; + decorators?: Decorators | Decorators[]; + } & Omit< + ComponentAnnotations & T['args']>, + 'decorators' | 'component' | 'args' + > + ): AngularMeta< + InferAngularTypes, Decorators>, + Omit, Decorators>>, 'args'> & { + args: {} extends TMetaArgs ? {} : TMetaArgs; + } + >; + + meta< + TArgs, + Decorators extends DecoratorFunction, + TMetaArgs extends Partial, + >( + meta: { + render?: ArgsStoryFn; + args?: TMetaArgs; + decorators?: Decorators | Decorators[]; + } & Omit< + ComponentAnnotations, + 'decorators' | 'args' | 'render' | 'component' + > + ): AngularMeta< + InferAngularTypes, + Omit>, 'args'> & { + args: {} extends TMetaArgs ? {} : TMetaArgs; + } + >; +} + +/** Extracts and unions all args types from an array of decorators. */ +type DecoratorsArgs = UnionToIntersection< + Decorators extends DecoratorFunction ? TArgs : unknown +>; + +/** + * Angular-specific Meta interface returned by `preview.meta()`. + * + * Provides the `story()` method to create individual stories with proper type inference. Args + * provided in meta become optional in stories, while missing required args must be provided at the + * story level. + */ +export interface AngularMeta< + T extends AngularRenderer, + MetaInput extends ComponentAnnotations, +> extends Meta { + /** + * Creates a story with a custom render function that takes no args. + * + * This overload allows you to define a story using just a render function or an object with a + * render function that doesn't depend on args. Since the render function doesn't use args, no + * args need to be provided regardless of what's required by the component. + * + * @example + * + * ```ts + * // Using just a render function + * export const CustomTemplate = meta.story(() => ({ + * template: '
Custom static content
', + * })); + * + * // Using an object with render + * export const WithRender = meta.story({ + * render: () => ({ template: '' }), + * }); + * ``` + */ + story< + TInput extends + | (() => AngularRenderer['storyResult']) + | (StoryAnnotations & { + render: () => AngularRenderer['storyResult']; + }), + >( + story: TInput + ): AngularStory< + T, + TInput extends () => AngularRenderer['storyResult'] ? { render: TInput } : TInput + >; + + /** + * Creates a story with custom configuration including args, decorators, or other annotations. + * + * This is the primary overload for defining stories. Args that were already provided in meta + * become optional, while any remaining required args must be specified here. + * + * @example + * + * ```ts + * // Provide required args not in meta + * export const Primary = meta.story({ + * args: { label: 'Click me', disabled: false }, + * }); + * + * // Override meta args and add story-specific configuration + * export const Disabled = meta.story({ + * args: { disabled: true }, + * decorators: [withCustomWrapper], + * }); + * ``` + */ + story< + TInput extends Simplify< + StoryAnnotations< + T, + T['args'], + SetOptional + > + >, + >( + story: TInput + ): AngularStory; + + /** + * Creates a story with no additional configuration. + * + * This overload is only available when all required args have been provided in meta. The + * conditional type `Partial extends SetOptional<...>` checks if the remaining required + * args (after accounting for args provided in meta) are all optional. If so, the function accepts + * zero arguments `[]`. Otherwise, it requires `[never]` which makes this overload unmatchable, + * forcing the user to provide args. + * + * @example + * + * ```ts + * // When meta provides all required args, story() can be called with no arguments + * const meta = preview.meta({ component: Button, args: { label: 'Hi', disabled: false } }); + * export const Default = meta.story(); // Valid - all args provided in meta + * ``` + */ + story( + ..._args: Partial extends SetOptional< + T['args'], + keyof T['args'] & keyof MetaInput['args'] + > + ? [] + : [never] + ): AngularStory; +} + +/** + * Angular-specific Story interface returned by `meta.story()`. + * + * Represents a single story with its configuration and provides access to the composed story for + * testing via `story.run()`. + */ +export interface AngularStory< + T extends AngularRenderer, + TInput extends StoryAnnotations, +> extends Story {} diff --git a/code/frameworks/angular-vite/src/client/public-types.ts b/code/frameworks/angular-vite/src/client/public-types.ts new file mode 100644 index 000000000000..8eba089f676f --- /dev/null +++ b/code/frameworks/angular-vite/src/client/public-types.ts @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import type { + AnnotatedStoryFn, + Args, + ComponentAnnotations, + DecoratorFunction, + LoaderFunction, + StoryAnnotations, + StoryContext as GenericStoryContext, + StrictArgs, + ProjectAnnotations, +} from 'storybook/internal/types'; +import type * as AngularCore from '@angular/core'; +import type { AngularRenderer } from './types'; + +export type { Args, ArgTypes, Parameters, StrictArgs } from 'storybook/internal/types'; +export type { Parameters as AngularParameters } from './types'; +export type { AngularRenderer }; + +/** + * Metadata to configure the stories for a component. + * + * @see [Default export](https://storybook.js.org/docs/api/csf#default-export) + */ +export type Meta = ComponentAnnotations< + AngularRenderer, + TransformComponentType +>; + +/** + * Story function that represents a CSFv2 component example. + * + * @see [Named Story exports](https://storybook.js.org/docs/api/csf#named-story-exports) + */ +export type StoryFn = AnnotatedStoryFn< + AngularRenderer, + TransformComponentType +>; + +/** + * Story object that represents a CSFv3 component example. + * + * @see [Named Story exports](https://storybook.js.org/docs/api/csf#named-story-exports) + */ +export type StoryObj = StoryAnnotations< + AngularRenderer, + TransformComponentType +>; + +export type Decorator = DecoratorFunction; +export type Loader = LoaderFunction; +export type StoryContext = GenericStoryContext; +export type Preview = ProjectAnnotations; + +/** Utility type that transforms InputSignal and EventEmitter types */ +export type TransformComponentType = TransformInputSignalType< + TransformOutputSignalType> +>; + +// @ts-ignore Angular < 17.2 doesn't export InputSignal +type AngularInputSignal = AngularCore.InputSignal; +// @ts-ignore Angular < 17.2 doesn't export InputSignalWithTransform +type AngularInputSignalWithTransform = AngularCore.InputSignalWithTransform; +// @ts-ignore Angular < 17.3 doesn't export AngularOutputEmitterRef +type AngularOutputEmitterRef = AngularCore.OutputEmitterRef; + +type AngularHasInputSignal = typeof AngularCore extends { input: infer U } ? true : false; +type AngularHasOutputSignal = typeof AngularCore extends { output: infer U } ? true : false; + +type InputSignal = AngularHasInputSignal extends true ? AngularInputSignal : never; +type InputSignalWithTransform = AngularHasInputSignal extends true + ? AngularInputSignalWithTransform + : never; +type OutputEmitterRef = AngularHasOutputSignal extends true ? AngularOutputEmitterRef : never; + +type TransformInputSignalType = { + [K in keyof T]: T[K] extends InputSignal + ? E + : T[K] extends InputSignalWithTransform + ? U + : T[K]; +}; + +type TransformOutputSignalType = { + [K in keyof T]: T[K] extends OutputEmitterRef ? (e: E) => void : T[K]; +}; + +type TransformEventType = { + [K in keyof T]: T[K] extends AngularCore.EventEmitter ? (e: E) => void : T[K]; +}; diff --git a/code/frameworks/angular-vite/src/client/render.ts b/code/frameworks/angular-vite/src/client/render.ts new file mode 100644 index 000000000000..768275b21e70 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/render.ts @@ -0,0 +1,26 @@ +import type { ArgsStoryFn, RenderContext } from 'storybook/internal/types'; + +import '@angular/compiler'; + +import { RendererFactory } from './angular-beta/RendererFactory'; +import type { AngularRenderer } from './types'; + +export const rendererFactory = new RendererFactory(); + +export const render: ArgsStoryFn = (props) => ({ props }); + +export async function renderToCanvas( + { storyFn, showMain, forceRemount, storyContext: { component } }: RenderContext, + element: HTMLElement +) { + showMain(); + + const renderer = await rendererFactory.getRendererInstance(element); + + await renderer.render({ + storyFnAngular: storyFn(), + component, + forced: !forceRemount, + targetDOMNode: element, + }); +} diff --git a/code/frameworks/angular-vite/src/client/types.ts b/code/frameworks/angular-vite/src/client/types.ts new file mode 100644 index 000000000000..f1b43189eedb --- /dev/null +++ b/code/frameworks/angular-vite/src/client/types.ts @@ -0,0 +1,49 @@ +import type { + Parameters as DefaultParameters, + StoryContext as DefaultStoryContext, + WebRenderer, +} from 'storybook/internal/types'; + +import type { ApplicationConfig, Provider } from '@angular/core'; + +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[]; + /** + * 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; +} + +export interface StoryFnAngularReturnType { + props?: ICollection; + moduleMetadata?: NgModuleMetadata; + applicationConfig?: ApplicationConfig; + template?: string; + styles?: string[]; + userDefinedTemplate?: boolean; +} + +export interface AngularRenderer extends WebRenderer { + component: any; + storyResult: StoryFnAngularReturnType; +} + +export type Parameters = DefaultParameters & { + bootstrapModuleOptions?: unknown; +}; + +export type StoryContext = DefaultStoryContext & { parameters: Parameters }; diff --git a/code/frameworks/angular-vite/src/index.ts b/code/frameworks/angular-vite/src/index.ts new file mode 100644 index 000000000000..a4b02907a15a --- /dev/null +++ b/code/frameworks/angular-vite/src/index.ts @@ -0,0 +1,16 @@ +export * from './client/index'; +export * from './types'; + +export { __definePreview as definePreview } from './client/index'; + +/* + * ATTENTION: + * - moduleMetadata + * - NgModuleMetadata + * - ICollection + * + * These typings are coped out of decorators.d.ts and types.d.ts in order to fix a bug with tsc + * It was imported out of dist before which was not the proper way of exporting public API + * + * This can be fixed by migrating app/angular to typescript + */ diff --git a/code/frameworks/angular-vite/src/node/index.ts b/code/frameworks/angular-vite/src/node/index.ts new file mode 100644 index 000000000000..31a74ec3447c --- /dev/null +++ b/code/frameworks/angular-vite/src/node/index.ts @@ -0,0 +1,7 @@ +import type { StorybookConfig } from '../types'; + +export function defineMain(config: StorybookConfig) { + return config; +} + +export type { StorybookConfig }; diff --git a/code/frameworks/angular-vite/src/preset.ts b/code/frameworks/angular-vite/src/preset.ts new file mode 100644 index 000000000000..74182697d98b --- /dev/null +++ b/code/frameworks/angular-vite/src/preset.ts @@ -0,0 +1,237 @@ +import type { PresetProperty } from 'storybook/internal/types'; + +import { fileURLToPath, resolve } from 'node:url'; + +import type { StandaloneOptions } from './builders/utils/standalone-options'; +import type { FrameworkOptions } from './types'; +import type { UserConfig, Plugin } from 'vite'; + +export const addons: PresetProperty<'addons'> = []; + +export const previewAnnotations: PresetProperty<'previewAnnotations'> = async ( + entries = [], + options +) => { + const config = fileURLToPath( + import.meta.resolve('@storybook/angular-vite/client/config'), + ); + const annotations = [...entries, config]; + + if ((options as any as StandaloneOptions).enableProdMode) { + const previewProdPath = fileURLToPath( + import.meta.resolve('@storybook/angular-vite/client/preview-prod'), + ); + annotations.unshift(previewProdPath); + } + + const docsConfig = await options.presets.apply('docs', {}, options); + const docsEnabled = Object.keys(docsConfig).length > 0; + if (docsEnabled) { + const docsConfigPath = fileURLToPath( + import.meta.resolve('@storybook/angular-vite/client/docs/config'), + ); + annotations.push(docsConfigPath); + } + return annotations; +}; + +export const core: PresetProperty<'core'> = async (config, options) => { + const framework = await options.presets.apply('framework'); + + return { + ...config, + builder: { + name: import.meta.resolve('@storybook/builder-vite'), + options: typeof framework === 'string' ? {} : framework.options.builder || {}, + }, + }; +}; + +async function resolveExperimentalZoneless( + frameworkOptions: FrameworkOptions, + angularBuilderOptions: StandaloneOptions['angularBuilderOptions'], +) { + // 1. Explicit framework option (user's .storybook/main.ts) + if (typeof frameworkOptions?.experimentalZoneless === 'boolean') { + return frameworkOptions.experimentalZoneless; + } + + // 2. Angular builder options (set by start-storybook/build-storybook) + if (typeof angularBuilderOptions?.experimentalZoneless === 'boolean') { + return angularBuilderOptions.experimentalZoneless; + } + + // 3. Auto-detect Angular 21+ (matches @storybook/angular builder behavior) + try { + const { VERSION } = await import('@angular/core'); + return !!(VERSION.major && Number(VERSION.major) >= 21); + } catch { + return false; + } +} + +export const viteFinal = async (config: UserConfig, options?: StandaloneOptions) => { + // Remove any loaded analogjs plugins from a vite.config.(m)ts file + config.plugins = (config.plugins ?? []) + .flat() + .filter((plugin: any) => !plugin.name.includes('analogjs')); + + // Merge custom configuration into the default config + const { mergeConfig, normalizePath } = await import('vite'); + const { default: angular } = await import('@analogjs/vite-plugin-angular'); + + // @ts-ignore + const framework = await options.presets.apply('framework'); + const experimentalZoneless = await resolveExperimentalZoneless( + framework.options, + options?.angularBuilderOptions, + ); + return mergeConfig(config, { + // Add dependencies to pre-optimization + optimizeDeps: { + include: [ + '@storybook/angular-vite/client', + '@storybook/angular-vite', + '@angular/compiler', + '@angular/platform-browser', + '@angular/platform-browser/animations', + 'tslib', + ...(experimentalZoneless ? [] : ['zone.js']), + ], + }, + plugins: [ + angular({ + jit: + typeof framework.options?.jit !== 'undefined' + ? framework.options?.jit + : true, + liveReload: + typeof framework.options?.liveReload !== 'undefined' + ? framework.options?.liveReload + : false, + tsconfig: + typeof framework.options?.tsconfig !== 'undefined' + ? framework.options?.tsconfig + : (options?.tsConfig ?? './.storybook/tsconfig.json'), + inlineStylesExtension: + typeof framework.options?.inlineStylesExtension !== 'undefined' + ? framework.options?.inlineStylesExtension + : 'css', + }), + angularOptionsPlugin(options, { normalizePath, experimentalZoneless }), + storybookEsbuildPlugin(), + ], + define: { + STORYBOOK_ANGULAR_OPTIONS: JSON.stringify({ + experimentalZoneless: !!experimentalZoneless, + }), + }, + }); +}; + +function angularOptionsPlugin( + options: StandaloneOptions, + { normalizePath, experimentalZoneless }: any, +): Plugin { + let resolvedConfig: UserConfig; + return { + name: 'storybook-angular-vite-options-plugin', + config(userConfig: UserConfig) { + resolvedConfig = userConfig; + const loadPaths = + options?.angularBuilderOptions?.stylePreprocessorOptions?.loadPaths; + const sassOptions = + options?.angularBuilderOptions?.stylePreprocessorOptions?.sass; + + if (Array.isArray(loadPaths)) { + const workspaceRoot = + options.angularBuilderContext?.workspaceRoot ?? + userConfig?.root ?? + process.cwd(); + return { + css: { + preprocessorOptions: { + scss: { + ...sassOptions, + loadPaths: loadPaths.map( + (loadPath) => `${resolve(workspaceRoot, loadPath)}`, + ), + }, + }, + }, + }; + } + + return; + }, + async transform(code, id) { + if ( + normalizePath(id).endsWith( + normalizePath(`${options.configDir}/preview.ts`), + ) + ) { + const imports = []; + const styles = options?.angularBuilderOptions?.styles; + + if (Array.isArray(styles)) { + styles.forEach((style) => { + imports.push(style); + }); + } + + if (!experimentalZoneless) { + imports.push('zone.js'); + } + + // Use vite config root when angularBuilderContext is not available + // (e.g., when running via Vitest instead of Angular builders) + const projectRoot = resolvedConfig?.root ?? process.cwd(); + + return { + code: ` + ${imports + .map((extraImport) => { + if ( + extraImport.startsWith('.') || + extraImport.startsWith('src') + ) { + // relative to root + return `import '${resolve(projectRoot, extraImport)}';`; + } + + // absolute import + return `import '${extraImport}';`; + }) + .join('\n')} + ${code} + `, + }; + } + + return; + }, + }; +} + +function storybookEsbuildPlugin() { + return { + name: 'storybookjs-angular-vite-esbuild-config', + apply: 'build', + config() { + return { + esbuild: { + // Don't mangle class names during the build + // This fixes display of compodoc argtypes + keepNames: true, + }, + }; + }, + }; +} + +export const typescript: PresetProperty<'typescript'> = async (config) => { + return { + ...config, + skipCompiler: true, + }; +}; diff --git a/code/frameworks/angular-vite/src/renderer.ts b/code/frameworks/angular-vite/src/renderer.ts new file mode 100644 index 000000000000..a88691381c84 --- /dev/null +++ b/code/frameworks/angular-vite/src/renderer.ts @@ -0,0 +1,6 @@ +export { storyPropsProvider } from './client/angular-beta/StorybookProvider'; +export { computesTemplateSourceFromComponent } from './client/angular-beta/ComputesTemplateFromComponent'; +export { rendererFactory } from './client/render'; +export { AbstractRenderer } from './client/angular-beta/AbstractRenderer'; +export { getApplication } from './client/angular-beta/StorybookModule'; +export { PropertyExtractor } from './client/angular-beta/utils/PropertyExtractor'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/empty-projects-entry/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/empty-projects-entry/angular.json new file mode 100644 index 000000000000..98cc50ef4b92 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/empty-projects-entry/angular.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "projects": {} +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/angular.json new file mode 100644 index 000000000000..f3dcfcd45586 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/angular.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "assets": [] + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/tsconfig.app.json new file mode 100644 index 000000000000..644f410d7fb1 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + }, + "exclude": ["karma.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/angular.json new file mode 100644 index 000000000000..358ec4f98f18 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/angular.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "options": { + "optimization": false, + "tsConfig": "src/tsconfig.app.json", + "assets": [] + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/tsconfig.app.json new file mode 100644 index 000000000000..644f410d7fb1 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + }, + "exclude": ["karma.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/angular.json new file mode 100644 index 000000000000..f55b743522a1 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/angular.json @@ -0,0 +1,64 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "assets": [] + } + } + } + }, + "no-confs-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "target-build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "assets": [] + } + } + } + }, + "no-target-conf-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "target-build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "assets": [] + }, + "configurations": { + "other-conf": { + "styles": ["src/styles.css"] + } + } + } + } + }, + "target-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "target-build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "assets": [] + }, + "configurations": { + "target-conf": { + "styles": ["src/styles.css"] + } + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/styles.css b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/styles.css new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/styles.css @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/tsconfig.app.json new file mode 100644 index 000000000000..644f410d7fb1 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + }, + "exclude": ["karma.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/angular.json new file mode 100644 index 000000000000..1e9be4468f64 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/angular.json @@ -0,0 +1,28 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "pattern-lib": { + "projectType": "library", + "root": "projects/pattern-lib", + "sourceRoot": "projects/pattern-lib/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "tsConfig": "projects/pattern-lib/tsconfig.lib.json", + "project": "projects/pattern-lib/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/pattern-lib/tsconfig.lib.prod.json" + } + } + } + } + } + }, + "defaultProject": "pattern-lib" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/tsconfig.lib.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/tsconfig.lib.json new file mode 100644 index 000000000000..b9a44f04464a --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/tsconfig.lib.json @@ -0,0 +1,20 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "../../out-tsc/lib", + "target": "es2015", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [], + "lib": ["dom", "es2018"] + }, + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "enableResourceInlining": true + }, + "exclude": ["src/test.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/nx.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/nx.json new file mode 100644 index 000000000000..1e0f6b56902f --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/nx.json @@ -0,0 +1,3 @@ +{ + "npmScope": "nx-example" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.css b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.css new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.css @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.scss b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.scss new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.scss @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/tsconfig.app.json new file mode 100644 index 000000000000..e5a395ac067d --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/tsconfig.app.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/tsconfig.json new file mode 100644 index 000000000000..4c19c82b6bab --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/tsconfig.json @@ -0,0 +1,14 @@ +{ + "include": ["./src"], + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/workspace.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/workspace.json new file mode 100644 index 000000000000..9d9fc9b3ef36 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/workspace.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "styles": ["src/styles.css", "src/styles.scss"] + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/angular.json new file mode 100644 index 000000000000..9d9fc9b3ef36 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/angular.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "styles": ["src/styles.css", "src/styles.scss"] + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/nx.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/nx.json new file mode 100644 index 000000000000..1e0f6b56902f --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/nx.json @@ -0,0 +1,3 @@ +{ + "npmScope": "nx-example" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.css b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.css new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.css @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.scss b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.scss new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.scss @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/tsconfig.app.json new file mode 100644 index 000000000000..e5a395ac067d --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/tsconfig.app.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/tsconfig.json new file mode 100644 index 000000000000..4c19c82b6bab --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/tsconfig.json @@ -0,0 +1,14 @@ +{ + "include": ["./src"], + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/angular.json new file mode 100644 index 000000000000..9d9fc9b3ef36 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/angular.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "styles": ["src/styles.css", "src/styles.scss"] + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.css b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.css new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.css @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.scss b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.scss new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.scss @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/tsconfig.app.json new file mode 100644 index 000000000000..644f410d7fb1 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + }, + "exclude": ["karma.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build-options/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build-options/angular.json new file mode 100644 index 000000000000..f734ee08896e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build-options/angular.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "architect": { + "build": {} + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build/angular.json new file mode 100644 index 000000000000..8eead199dffd --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build/angular.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "projects": { "foo-project": {} }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-compatible-projects/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-compatible-projects/angular.json new file mode 100644 index 000000000000..2e3757eb833e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-compatible-projects/angular.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "projects": { + "noop-project": {} + }, + "defaultProject": "missing-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/angular.json new file mode 100644 index 000000000000..61a2092b1b7f --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/angular.json @@ -0,0 +1,3 @@ +{ + "version": 1 +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/tsconfig.lib.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/tsconfig.lib.json new file mode 100644 index 000000000000..b9a44f04464a --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/tsconfig.lib.json @@ -0,0 +1,20 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "../../out-tsc/lib", + "target": "es2015", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [], + "lib": ["dom", "es2018"] + }, + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "enableResourceInlining": true + }, + "exclude": ["src/test.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/angular.json new file mode 100644 index 000000000000..2b203f2551ec --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/angular.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "architect": { + "build": { + "options": { + "assets": [] + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/tsconfig.app.json new file mode 100644 index 000000000000..644f410d7fb1 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + }, + "exclude": ["karma.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__tests__/angular.json b/code/frameworks/angular-vite/src/server/__tests__/angular.json new file mode 100644 index 000000000000..d703e396ff22 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__tests__/angular.json @@ -0,0 +1,96 @@ +{ + /* angular.json can have comments */ + // angular.json can have comments + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "angular-cli": { + "root": "", + "sourceRoot": "src", + "projectType": "application", + "prefix": "app", + "schematics": {}, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/angular-cli", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": ["src/favicon.ico", "src/assets"], + "styles": ["src/styles.css", "src/styles.scss"], + "stylePreprocessorOptions": { + "includePaths": ["src/commons"] + }, + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "angular-cli:build" + }, + "configurations": { + "production": { + "browserTarget": "angular-cli:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "angular-cli:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/karma.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": ["styles.css"], + "scripts": [], + "assets": ["src/favicon.ico", "src/assets"] + } + } + } + }, + "angular-cli-e2e": { + "root": "e2e/", + "projectType": "application", + "architect": { + "e2e": { + "builder": "@angular-devkit/build-angular:protractor", + "options": { + "protractorConfig": "e2e/protractor.conf.js", + "devServerTarget": "angular-cli:serve" + } + } + } + } + }, + "defaultProject": "angular-cli" +} diff --git a/code/frameworks/angular-vite/src/server/preset-options.ts b/code/frameworks/angular-vite/src/server/preset-options.ts new file mode 100644 index 000000000000..8d3d9c4f0b31 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/preset-options.ts @@ -0,0 +1,14 @@ +import type { Options as CoreOptions } from 'storybook/internal/types'; + +import type { BuilderContext } from '@angular-devkit/architect'; +import type { StandaloneOptions } from '../builders/utils/standalone-options'; + +export type PresetOptions = CoreOptions & { + /* Allow to get the options of a targeted "browser builder" */ + angularBrowserTarget?: string | null; + /* Defined set of options. These will take over priority from angularBrowserTarget options */ + angularBuilderOptions?: StandaloneOptions['angularBuilderOptions']; + /* Angular context from builder */ + angularBuilderContext?: BuilderContext | null; + tsConfig?: string; +}; diff --git a/code/frameworks/angular-vite/src/test-setup.ts b/code/frameworks/angular-vite/src/test-setup.ts new file mode 100644 index 000000000000..5ed15c667bad --- /dev/null +++ b/code/frameworks/angular-vite/src/test-setup.ts @@ -0,0 +1,8 @@ +import '@analogjs/vite-plugin-angular/setup-vitest'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); diff --git a/code/frameworks/angular-vite/src/types.ts b/code/frameworks/angular-vite/src/types.ts new file mode 100644 index 000000000000..eb5dd93b2561 --- /dev/null +++ b/code/frameworks/angular-vite/src/types.ts @@ -0,0 +1,42 @@ +import { CompatibleString } from 'storybook/internal/types'; + +import { StorybookConfig as StorybookConfigBase } from 'storybook/internal/types'; + +import type { BuilderOptions, StorybookConfigVite } from '@storybook/builder-vite'; + +type FrameworkName = CompatibleString<'@storybook/angular-vite'>; +type BuilderName = CompatibleString<'@storybook/builder-vite'>; + +export type FrameworkOptions = { + builder?: BuilderOptions; + jit?: boolean; + liveReload?: boolean; + inlineStylesExtension?: string; + tsconfig?: string; + experimentalZoneless?: boolean; +}; + +type StorybookConfigFramework = { + framework: + | FrameworkName + | { + name: FrameworkName; + options: FrameworkOptions; + }; + core?: StorybookConfigBase['core'] & { + builder?: + | BuilderName + | { + name: BuilderName; + options: BuilderOptions; + }; + }; +}; + +/** The interface for Storybook configuration in `main.ts` files. */ +export type StorybookConfig = Omit< + StorybookConfigBase, + keyof StorybookConfigVite | keyof StorybookConfigFramework +> & + StorybookConfigVite & + StorybookConfigFramework; diff --git a/code/frameworks/angular-vite/src/typings.d.ts b/code/frameworks/angular-vite/src/typings.d.ts new file mode 100644 index 000000000000..4b9bdbb7ac91 --- /dev/null +++ b/code/frameworks/angular-vite/src/typings.d.ts @@ -0,0 +1,18 @@ +// will be provided by the vite define config +declare var NODE_ENV: string | undefined; + +declare var __STORYBOOK_ADDONS_CHANNEL__: any; +declare var __STORYBOOK_ADDONS_PREVIEW: any; +declare var __STORYBOOK_COMPODOC_JSON__: any; +declare var __STORYBOOK_PREVIEW__: any; +declare var __STORYBOOK_STORY_STORE__: any; +declare var CHANNEL_OPTIONS: any; +declare var DOCS_OPTIONS: any; + +declare var FEATURES: import('storybook/internal/types').StorybookConfigRaw['features']; + +declare var IS_STORYBOOK: any; +declare var LOGLEVEL: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' | undefined; +declare var STORIES: any; +declare var STORYBOOK_ENV: 'angular'; +declare var STORYBOOK_HOOKS_CONTEXT: any; diff --git a/code/frameworks/angular-vite/start-schema.json b/code/frameworks/angular-vite/start-schema.json new file mode 100644 index 000000000000..70b307e107bc --- /dev/null +++ b/code/frameworks/angular-vite/start-schema.json @@ -0,0 +1,227 @@ +{ + "$schema": "http://json-schema.org/schema", + "title": "Start Storybook", + "description": "Serve up storybook in development mode.", + "type": "object", + "properties": { + "browserTarget": { + "type": "string", + "description": "Build target to be served in project-name:builder:config format. Should generally target on the builder: '@angular-devkit/build-angular:browser'. Useful for Storybook to use options (styles, assets, ...).", + "pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$" + }, + "tsConfig": { + "type": "string", + "description": "The full path for the TypeScript configuration file, relative to the current workspace." + }, + "preserveSymlinks": { + "type": "boolean", + "description": "Do not use the real path when resolving modules. If true, symlinks are resolved to their real path, if false, symlinks are resolved to their symlinked path.", + "default": false + }, + "port": { + "type": "number", + "description": "Port to listen on.", + "default": 9009 + }, + "host": { + "type": "string", + "description": "Host to listen on.", + "default": "localhost" + }, + "configDir": { + "type": "string", + "description": "Directory where to load Storybook configurations from.", + "default": ".storybook" + }, + "https": { + "type": "boolean", + "description": "Serve Storybook over HTTPS. Note: You must provide your own certificate information.", + "default": false + }, + "sslCa": { + "type": "string", + "description": "Provide an SSL certificate authority. (Optional with --https, required if using a self-signed certificate)." + }, + "sslCert": { + "type": "string", + "description": "Provide an SSL certificate. (Required with --https)." + }, + "sslKey": { + "type": "string", + "description": "SSL key to use for serving HTTPS." + }, + "smokeTest": { + "type": "boolean", + "description": "Exit after successful start.", + "default": false + }, + "ci": { + "type": "boolean", + "description": "CI mode (skip interactive prompts, don't open browser).", + "default": false + }, + "open": { + "type": "boolean", + "description": "Whether to open Storybook automatically in the browser.", + "default": true + }, + "quiet": { + "type": "boolean", + "description": "Suppress verbose build output.", + "default": false + }, + "enableProdMode": { + "type": "boolean", + "description": "Disable Angular's development mode, which turns off assertions and other checks within the framework.", + "default": false + }, + "docs": { + "type": "boolean", + "description": "Starts Storybook in documentation mode. Learn more about it : https://storybook.js.org/docs/writing-docs/build-documentation#preview-storybooks-documentation.", + "default": false + }, + "compodoc": { + "type": "boolean", + "description": "Execute compodoc before.", + "default": true + }, + "compodocArgs": { + "type": "array", + "description": "Compodoc options : https://compodoc.app/guides/options.html. Options `-p` with tsconfig path and `-d` with workspace root is always given.", + "default": ["-e", "json"], + "items": { + "type": "string" + } + }, + "styles": { + "type": "array", + "description": "Global styles to be included in the build.", + "items": { + "$ref": "#/definitions/styleElement" + } + }, + "stylePreprocessorOptions": { + "description": "Options to pass to style preprocessors.", + "type": "object", + "properties": { + "includePaths": { + "description": "Paths to include. Paths will be resolved to workspace root.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "assets": { + "type": "array", + "description": "List of static application assets.", + "default": [], + "items": { + "$ref": "#/definitions/assetPattern" + } + }, + "initialPath": { + "type": "string", + "description": "URL path to be appended when visiting Storybook for the first time" + }, + "statsJson": { + "type": ["boolean", "string"], + "description": "Write stats JSON to disk", + "default": false + }, + "previewUrl": { + "type": "string", + "description": "Disables the default storybook preview and lets you use your own" + }, + "loglevel": { + "type": "string", + "description": "Controls level of logging during build. Can be one of: [trace, debug, info (default), warn, error, silent].", + "pattern": "(trace|debug|info|warn|error|silent)" + }, + "logfile": { + "type": "string", + "description": "If provided, the log output will be written to the specified file path." + }, + "sourceMap": { + "type": ["boolean", "object"], + "description": "Configure sourcemaps. See: https://angular.io/guide/workspace-config#source-map-configuration", + "default": false + }, + "experimentalZoneless": { + "type": "boolean", + "description": "Experimental: Use zoneless change detection." + } + }, + "additionalProperties": false, + "definitions": { + "assetPattern": { + "oneOf": [ + { + "type": "object", + "properties": { + "followSymlinks": { + "type": "boolean", + "default": false, + "description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched." + }, + "glob": { + "type": "string", + "description": "The pattern to match." + }, + "input": { + "type": "string", + "description": "The input directory path in which to apply 'glob'. Defaults to the project root." + }, + "ignore": { + "description": "An array of globs to ignore.", + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "string", + "description": "Absolute path within the output." + } + }, + "additionalProperties": false, + "required": ["glob", "input", "output"] + }, + { + "type": "string" + } + ] + }, + "styleElement": { + "oneOf": [ + { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The file to include." + }, + "bundleName": { + "type": "string", + "pattern": "^[\\w\\-.]*$", + "description": "The bundle name for this extra entry point." + }, + "inject": { + "type": "boolean", + "description": "If the bundle will be referenced in the HTML file.", + "default": true + } + }, + "additionalProperties": false, + "required": ["input"] + }, + { + "type": "string", + "description": "The file to include." + } + ] + } + } +} diff --git a/code/frameworks/angular-vite/template/cli/button.component.ts b/code/frameworks/angular-vite/template/cli/button.component.ts new file mode 100644 index 000000000000..a1cdf6a939e3 --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/button.component.ts @@ -0,0 +1,48 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, Output, EventEmitter } from '@angular/core'; + +@Component({ + selector: 'storybook-button', + standalone: true, + imports: [CommonModule], + template: ` `, + styleUrls: ['./button.css'], +}) +export class ButtonComponent { + /** Is this the principal call to action on the page? */ + @Input() + primary = false; + + /** What background color to use */ + @Input() + backgroundColor?: string; + + /** How large should the button be? */ + @Input() + size: 'small' | 'medium' | 'large' = 'medium'; + + /** + * Button contents + * + * @required + */ + @Input() + label = 'Button'; + + /** Optional click handler */ + @Output() + onClick = new EventEmitter(); + + public get classes(): string[] { + const mode = this.primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + + return ['storybook-button', `storybook-button--${this.size}`, mode]; + } +} diff --git a/code/frameworks/angular-vite/template/cli/button.stories.ts b/code/frameworks/angular-vite/template/cli/button.stories.ts new file mode 100644 index 000000000000..4870e4cff917 --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/button.stories.ts @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { fn } from 'storybook/test'; + +import { ButtonComponent } from './button.component'; + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories +const meta: Meta = { + title: 'Example/Button', + component: ButtonComponent, + tags: ['autodocs'], + argTypes: { + backgroundColor: { + control: 'color', + }, + }, + // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args + args: { onClick: fn() }, +}; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const Primary: Story = { + args: { + primary: true, + label: 'Button', + }, +}; + +export const Secondary: Story = { + args: { + label: 'Button', + }, +}; + +export const Large: Story = { + args: { + size: 'large', + label: 'Button', + }, +}; + +export const Small: Story = { + args: { + size: 'small', + label: 'Button', + }, +}; diff --git a/code/frameworks/angular-vite/template/cli/header.component.ts b/code/frameworks/angular-vite/template/cli/header.component.ts new file mode 100644 index 000000000000..4d3a4fdc53b6 --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/header.component.ts @@ -0,0 +1,76 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { ButtonComponent } from './button.component'; +import type { User } from './user'; + +@Component({ + selector: 'storybook-header', + standalone: true, + imports: [CommonModule, ButtonComponent], + template: `
+
+
+ + + + + + + +

Acme

+
+
+
+ + Welcome, {{ user.name }}! + + +
+
+ + +
+
+
+
`, + styleUrls: ['./header.css'], +}) +export class HeaderComponent { + @Input() + user: User | null = null; + + @Output() + onLogin = new EventEmitter(); + + @Output() + onLogout = new EventEmitter(); + + @Output() + onCreateAccount = new EventEmitter(); +} diff --git a/code/frameworks/angular-vite/template/cli/header.stories.ts b/code/frameworks/angular-vite/template/cli/header.stories.ts new file mode 100644 index 000000000000..8a9f73b151c8 --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/header.stories.ts @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { fn } from 'storybook/test'; + +import { HeaderComponent } from './header.component'; + +const meta: Meta = { + title: 'Example/Header', + component: HeaderComponent, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + layout: 'fullscreen', + }, + args: { + onLogin: fn(), + onLogout: fn(), + onCreateAccount: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const LoggedIn: Story = { + args: { + user: { + name: 'Jane Doe', + }, + }, +}; + +export const LoggedOut: Story = {}; diff --git a/code/frameworks/angular-vite/template/cli/page.component.ts b/code/frameworks/angular-vite/template/cli/page.component.ts new file mode 100644 index 000000000000..fca9d299d000 --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/page.component.ts @@ -0,0 +1,82 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { HeaderComponent } from './header.component'; +import type { User } from './user'; + +@Component({ + selector: 'storybook-page', + standalone: true, + imports: [CommonModule, HeaderComponent], + template: `
+ +
+

Pages in Storybook

+

+ We recommend building UIs with a + + component-driven + + process starting with atomic components and ending with pages. +

+

+ Render pages with mock data. This makes it easy to build and review page states without + needing to navigate to them in your app. Here are some handy patterns for managing page data + in Storybook: +

+
    +
  • + Use a higher-level connected component. Storybook helps you compose such data from the + "args" of child component stories +
  • +
  • + Assemble data in the page component from your services. You can mock these services out + using Storybook. +
  • +
+

+ Get a guided tutorial on component-driven development at + + Storybook tutorials + + . Read more in the + docs + . +

+
+ Tip Adjust the width of the canvas with the + + + + + + Viewports addon in the toolbar +
+
+
`, + styleUrls: ['./page.css'], +}) +export class PageComponent { + user: User | null = null; + + doLogout() { + this.user = null; + } + + doLogin() { + this.user = { name: 'Jane Doe' }; + } + + doCreateAccount() { + this.user = { name: 'Jane Doe' }; + } +} diff --git a/code/frameworks/angular-vite/template/cli/page.stories.ts b/code/frameworks/angular-vite/template/cli/page.stories.ts new file mode 100644 index 000000000000..659a14c1effb --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/page.stories.ts @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { expect, userEvent, within } from 'storybook/test'; + +import { PageComponent } from './page.component'; + +const meta: Meta = { + title: 'Example/Page', + component: PageComponent, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +export const LoggedOut: Story = {}; + +// More on component testing: https://storybook.js.org/docs/writing-tests/interaction-testing +export const LoggedIn: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const loginButton = canvas.getByRole('button', { name: /Log in/i }); + await expect(loginButton).toBeInTheDocument(); + await userEvent.click(loginButton); + await expect(loginButton).not.toBeInTheDocument(); + + const logoutButton = canvas.getByRole('button', { name: /Log out/i }); + await expect(logoutButton).toBeInTheDocument(); + }, +}; diff --git a/code/frameworks/angular-vite/template/cli/user.ts b/code/frameworks/angular-vite/template/cli/user.ts new file mode 100644 index 000000000000..c66461927e2a --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/user.ts @@ -0,0 +1,3 @@ +export interface User { + name: string; +} diff --git a/code/frameworks/angular-vite/template/components/button.component.ts b/code/frameworks/angular-vite/template/components/button.component.ts new file mode 100644 index 000000000000..4407ead9b050 --- /dev/null +++ b/code/frameworks/angular-vite/template/components/button.component.ts @@ -0,0 +1,47 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; + +@Component({ + standalone: false, + // Needs to be a different name to the CLI template button + selector: 'storybook-framework-button', + template: ` `, + styleUrls: ['./button.css'], +}) +export default class FrameworkButtonComponent { + /** Is this the principal call to action on the page? */ + @Input() + primary = false; + + /** What background color to use */ + @Input() + backgroundColor?: string; + + /** How large should the button be? */ + @Input() + size: 'small' | 'medium' | 'large' = 'medium'; + + /** + * Button contents + * + * @required + */ + @Input() + label = 'Button'; + + /** Optional click handler */ + @Output() + onClick = new EventEmitter(); + + public get classes(): string[] { + const mode = this.primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + + return ['storybook-button', `storybook-button--${this.size}`, mode]; + } +} diff --git a/code/frameworks/angular-vite/template/components/button.css b/code/frameworks/angular-vite/template/components/button.css new file mode 100644 index 000000000000..4e3620b0dcbf --- /dev/null +++ b/code/frameworks/angular-vite/template/components/button.css @@ -0,0 +1,30 @@ +.storybook-button { + display: inline-block; + cursor: pointer; + border: 0; + border-radius: 3em; + font-weight: 700; + line-height: 1; + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} +.storybook-button--primary { + background-color: #555ab9; + color: white; +} +.storybook-button--secondary { + box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; + background-color: transparent; + color: #333; +} +.storybook-button--small { + padding: 10px 16px; + font-size: 12px; +} +.storybook-button--medium { + padding: 11px 20px; + font-size: 14px; +} +.storybook-button--large { + padding: 12px 24px; + font-size: 16px; +} diff --git a/code/frameworks/angular-vite/template/components/form.component.ts b/code/frameworks/angular-vite/template/components/form.component.ts new file mode 100644 index 000000000000..0d7316454a51 --- /dev/null +++ b/code/frameworks/angular-vite/template/components/form.component.ts @@ -0,0 +1,39 @@ +import { Component, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +@Component({ + standalone: true, + imports: [FormsModule], + selector: 'storybook-form', + template: ` +
+ + + @if (complete()) { +

Completed!!

+ } +
+ `, +}) +export default class FormComponent { + /** Optional success handler */ + onSuccess = output(); + + value = ''; + + complete = signal(false); + + handleSubmit(event: SubmitEvent) { + event.preventDefault(); + this.onSuccess.emit(this.value); + setTimeout(() => { + this.complete.set(true); + }, 500); + setTimeout(() => { + this.complete.set(false); + }, 1500); + } +} diff --git a/code/frameworks/angular-vite/template/components/html.component.ts b/code/frameworks/angular-vite/template/components/html.component.ts new file mode 100644 index 000000000000..bcb98a198a9d --- /dev/null +++ b/code/frameworks/angular-vite/template/components/html.component.ts @@ -0,0 +1,25 @@ +import { Component, Input } from '@angular/core'; +// DomSanitizer must be a regular import, not a type-only import, because it's used in dependency injection. +// Type-only imports are stripped during compilation, causing runtime errors like "DomSanitizer is not defined". +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { DomSanitizer } from '@angular/platform-browser'; +@Component({ + standalone: false, + selector: 'storybook-html', + template: `
`, +}) +export default class HtmlComponent { + /** + * The HTML to render + * + * @required + */ + @Input() + content = ''; + + constructor(private sanitizer: DomSanitizer) {} + + get safeContent() { + return this.sanitizer.bypassSecurityTrustHtml(this.content); + } +} diff --git a/code/frameworks/angular-vite/template/components/index.js b/code/frameworks/angular-vite/template/components/index.js new file mode 100644 index 000000000000..bb4f150af3b9 --- /dev/null +++ b/code/frameworks/angular-vite/template/components/index.js @@ -0,0 +1,6 @@ +import Button from './button.component'; +import Form from './form.component'; +import Html from './html.component'; +import Pre from './pre.component'; + +globalThis.__TEMPLATE_COMPONENTS__ = { Button, Html, Pre, Form }; diff --git a/code/frameworks/angular-vite/template/components/pre.component.ts b/code/frameworks/angular-vite/template/components/pre.component.ts new file mode 100644 index 000000000000..b71346e23825 --- /dev/null +++ b/code/frameworks/angular-vite/template/components/pre.component.ts @@ -0,0 +1,24 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-pre', + template: `
{{ finalText }}
`, +}) +export default class PreComponent { + /** Styles to apply to the component */ + @Input() + style?: object; + + /** An object to render */ + @Input() + object?: object; + + /** The code to render */ + @Input() + text?: string; + + get finalText() { + return this.object ? JSON.stringify(this.object, null, 2) : this.text; + } +} diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.html b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.html new file mode 100644 index 000000000000..7af61d6f344d --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.html @@ -0,0 +1,7 @@ + diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.scss b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.scss new file mode 100644 index 000000000000..52c3e2bf0e20 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.scss @@ -0,0 +1,3 @@ +.btn-primary { + background-color: #ff9899; +} diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.ts new file mode 100644 index 000000000000..2e7c0fe0aaee --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.ts @@ -0,0 +1,234 @@ +import type { ElementRef } from '@angular/core'; +import { + Component, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and `inline code`.> How you like dem apples?! It's never been easier to + * document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code `ThingThing` + * @html aaa + */ +@Component({ + standalone: false, + selector: 'my-button', + templateUrl: './doc-button.component.html', + styleUrls: ['./doc-button.component.scss'], +}) +export class DocButtonComponent { + @ViewChild('buttonRef', { static: false }) buttonRef!: ElementRef; + + /** Test default value. */ + @Input() + public theDefaultValue = 'Default value in component'; + + /** + * Setting default value here because compodoc won't get the default value for accessors + * + * @default Another default value + */ + @Input() + get anotherDefaultValue() { + return this._anotherDefaultValue; + } + + set anotherDefaultValue(v: string) { + this._anotherDefaultValue = v; + } + + _anotherDefaultValue = 'Another default value'; + + /** Test null default value. */ + @Input() + public aNullValue: string | null = null; + + /** Test null default value. */ + @Input() + public anUndefinedValue: undefined; + + /** Test numeric default value. */ + @Input() + public aNumericValue = 123; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent = ButtonAccent.Normal; + + /** + * Specifies some arbitrary object. This comment is to test certain chars like apostrophes - it's + * working + */ + @Input() public someDataObject!: ISomeInterface; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label!: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if `isDisabled` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the `ignore` + * annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for `inputValue` that is also an `@Input`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for `inputValue`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event']) + onClickListener(event: Event) { + console.log('button', event.target); + this.handleClick(event); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => `btn-${_class}`); + } + + /** @ignore */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = `${value}`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds `x` and `y` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have `parseInt()` applied before + * calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(`${y}`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some `id`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some `password`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey!: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem!: T[]; +} diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.stories.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.stories.ts new file mode 100644 index 000000000000..964ee32a734b --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.stories.ts @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { argsToTemplate } from '@storybook/angular'; + +import { DocButtonComponent } from './doc-button.component'; + +const meta: Meta> = { + component: DocButtonComponent, +}; + +export default meta; + +type Story = StoryObj>; + +export const Basic: Story = { + args: { label: 'Args test', isDisabled: false }, + argTypes: { + theDefaultValue: { + table: { + defaultValue: { summary: 'Basic default value' }, + }, + }, + }, +}; + +export const WithTemplate: Story = { + args: { label: 'Template test', appearance: 'primary' }, + render: (args) => ({ + props: args, + template: ``, + }), +}; diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.directive.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.directive.ts new file mode 100644 index 000000000000..cfc7e7f9e5fa --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.directive.ts @@ -0,0 +1,22 @@ +// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. +// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ElementRef, AfterViewInit, Directive, Input } from '@angular/core'; + +/** This is an Angular Directive example that has a Prop Table. */ +@Directive({ + standalone: false, + selector: '[docDirective]', +}) +export class DocDirective implements AfterViewInit { + constructor(private ref: ElementRef) {} + + /** Will apply gray background color if set to true. */ + @Input() hasGrayBackground = false; + + ngAfterViewInit(): void { + if (this.hasGrayBackground) { + this.ref.nativeElement.style = 'background-color: lightgray'; + } + } +} diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.stories.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.stories.ts new file mode 100644 index 000000000000..efc0bad9de2e --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.stories.ts @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { DocDirective } from './doc-directive.directive'; + +const meta: Meta = { + component: DocDirective, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: () => ({ + moduleMetadata: { + declarations: [DocDirective], + }, + template: '

DocDirective

', + }), +}; diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.service.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.service.ts new file mode 100644 index 000000000000..5fe4fa2d2478 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; + +/** This is an Angular Injectable example that has a Prop Table. */ +@Injectable({ + providedIn: 'root', +}) +export class DocInjectableService { + /** Auth headers to use. */ + auth: any; + + constructor() { + this.auth = new HttpHeaders({ 'Content-Type': 'application/json' }); + } + + /** Get posts from Backend. */ + getPosts(): unknown[] { + return []; + } +} diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.stories.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.stories.ts new file mode 100644 index 000000000000..d98e803c885a --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.stories.ts @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { DocInjectableService } from './doc-injectable.service'; + +const meta: Meta = { + component: DocInjectableService, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: () => ({ + moduleMetadata: { + providers: [DocInjectableService], + }, + template: '

DocInjectable

', + }), +}; diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.pipe.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.pipe.ts new file mode 100644 index 000000000000..e1af0d8eeb29 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.pipe.ts @@ -0,0 +1,18 @@ +import type { PipeTransform } from '@angular/core'; +import { Pipe } from '@angular/core'; + +/** This is an Angular Pipe example that has a Prop Table. */ +@Pipe({ + standalone: false, + name: 'docPipe', +}) +export class DocPipe implements PipeTransform { + /** + * Transforms a string into uppercase. + * + * @param value String + */ + transform(value: string): string { + return value?.toUpperCase(); + } +} diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.stories.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.stories.ts new file mode 100644 index 000000000000..ec57d46b7850 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.stories.ts @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { DocPipe } from './doc-pipe.pipe'; + +const meta: Meta = { + component: DocPipe, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: () => ({ + moduleMetadata: { + declarations: [DocPipe], + }, + template: `

{{ 'DocPipe' | docPipe }}

`, + }), +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/README.mdx b/code/frameworks/angular-vite/template/stories/basics/README.mdx new file mode 100644 index 000000000000..f2f64c9634f6 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/README.mdx @@ -0,0 +1,7 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Examples for Angular features + +These examples serve to highlight the right Storybook operation for basics Angular features diff --git a/code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva-component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva-component.stories.ts new file mode 100644 index 000000000000..ffcf562390ea --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva-component.stories.ts @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { StoryFn, moduleMetadata } from '@storybook/angular'; + +import { FormsModule } from '@angular/forms'; + +import { CustomCvaComponent } from './custom-cva.component'; + +const meta: Meta = { + // title: 'Basics / Angular forms / ControlValueAccessor', + component: CustomCvaComponent, + decorators: [ + moduleMetadata({ + imports: [FormsModule], + }), + (storyFn) => { + const story = storyFn(); + console.log(story); + return story; + }, + ], +} as Meta; + +export default meta; + +type Story = StoryObj; + +export const SimpleInput: Story = { + name: 'Simple input', + render: () => ({ + props: { + ngModel: 'Type anything', + ngModelChange: () => {}, + }, + }), +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva.component.ts b/code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva.component.ts new file mode 100644 index 000000000000..758800852406 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva.component.ts @@ -0,0 +1,57 @@ +import { Component, forwardRef } from '@angular/core'; +import type { ControlValueAccessor } from '@angular/forms'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; + +const NOOP = () => {}; + +@Component({ + standalone: false, + selector: 'storybook-custom-cva-component', + template: `
{{ value }}
+ `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CustomCvaComponent), + multi: true, + }, + ], +}) +export class CustomCvaComponent implements ControlValueAccessor { + disabled?: boolean; + + protected onChange: (value: any) => void = NOOP; + + protected onTouch: () => void = NOOP; + + protected internalValue: any; + + get value(): any { + return this.internalValue; + } + + set value(value: any) { + if (value !== this.internalValue) { + this.internalValue = value; + this.onChange(value); + } + } + + writeValue(value: any): void { + if (value !== this.internalValue) { + this.internalValue = value; + } + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouch = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selector.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selector.component.ts new file mode 100644 index 000000000000..78fd72adcecb --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selector.component.ts @@ -0,0 +1,26 @@ +// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. +// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ComponentFactoryResolver, ElementRef, Component } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-attribute-selector[foo=bar]', + template: `

Attribute selector

+Selector: {{ selectors }}
+Generated template: {{ generatedTemplate }}`, +}) +export class AttributeSelectorComponent { + generatedTemplate!: string; + + selectors!: string; + + constructor( + public el: ElementRef, + private resolver: ComponentFactoryResolver + ) { + const factory = this.resolver.resolveComponentFactory(AttributeSelectorComponent); + this.selectors = factory.selector; + this.generatedTemplate = el.nativeElement.outerHTML; + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selectors.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selectors.component.stories.ts new file mode 100644 index 000000000000..2d9e13dd4d78 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selectors.component.stories.ts @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { AttributeSelectorComponent } from './attribute-selector.component'; + +const meta: Meta = { + // title: 'Basics / Component / With Complex Selectors', + component: AttributeSelectorComponent, +}; + +export default meta; + +type Story = StoryObj; + +export const AttributeSelectors: Story = {}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.stories.ts new file mode 100644 index 000000000000..c8da5e41e2c4 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.stories.ts @@ -0,0 +1,8 @@ +import { ClassSelectorComponent } from './class-selector.component'; + +export default { + // title: 'Basics / Component / With Complex Selectors', + component: ClassSelectorComponent, +}; + +export const ClassSelectors = {}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.ts new file mode 100644 index 000000000000..d6cbf90d2f4a --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.ts @@ -0,0 +1,26 @@ +// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. +// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ComponentFactoryResolver, ElementRef, Component } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-class-selector.foo, storybook-class-selector.bar', + template: `

Class selector

+Selector: {{ selectors }}
+Generated template: {{ generatedTemplate }}`, +}) +export class ClassSelectorComponent { + generatedTemplate!: string; + + selectors!: string; + + constructor( + public el: ElementRef, + private resolver: ComponentFactoryResolver + ) { + const factory = this.resolver.resolveComponentFactory(ClassSelectorComponent); + this.selectors = factory.selector; + this.generatedTemplate = el.nativeElement.outerHTML; + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-class-selector.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-class-selector.component.stories.ts new file mode 100644 index 000000000000..0ed46ecfdbcd --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-class-selector.component.stories.ts @@ -0,0 +1,8 @@ +import { MultipleClassSelectorComponent } from './multiple-selector.component'; + +export default { + // title: 'Basics / Component / With Complex Selectors', + component: MultipleClassSelectorComponent, +}; + +export const MultipleClassSelectors = {}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.stories.ts new file mode 100644 index 000000000000..3dac394c440a --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.stories.ts @@ -0,0 +1,8 @@ +import { MultipleSelectorComponent } from './multiple-selector.component'; + +export default { + // title: 'Basics / Component / With Complex Selectors', + component: MultipleSelectorComponent, +}; + +export const MultipleSelectors = {}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.ts new file mode 100644 index 000000000000..14a70bc230de --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.ts @@ -0,0 +1,48 @@ +// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. +// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ComponentFactoryResolver, ElementRef, Component } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-multiple-selector, storybook-multiple-selector2', + template: `

Multiple selector

+Selector: {{ selectors }}
+Generated template: {{ generatedTemplate }}`, +}) +export class MultipleSelectorComponent { + generatedTemplate!: string; + + selectors!: string; + + constructor( + public el: ElementRef, + private resolver: ComponentFactoryResolver + ) { + const factory = this.resolver.resolveComponentFactory(MultipleClassSelectorComponent); + this.selectors = factory.selector; + this.generatedTemplate = el.nativeElement.outerHTML; + } +} + +@Component({ + standalone: false, + selector: 'storybook-button, button[foo], .button[foo], button[baz]', + template: `

Multiple selector

+Selector: {{ selectors }}
+Generated template: {{ generatedTemplate }}`, +}) +export class MultipleClassSelectorComponent { + generatedTemplate!: string; + + selectors!: string; + + constructor( + public el: ElementRef, + private resolver: ComponentFactoryResolver + ) { + const factory = this.resolver.resolveComponentFactory(MultipleClassSelectorComponent); + this.selectors = factory.selector; + this.generatedTemplate = el.nativeElement.outerHTML; + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.html b/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.html new file mode 100644 index 000000000000..08584b9824f4 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.html @@ -0,0 +1,8 @@ +
+
unionType: {{ unionType }}
+
aliasedUnionType: {{ aliasedUnionType }}
+
enumNumeric: {{ enumNumeric }}
+
enumNumericInitial: {{ enumNumericInitial }}
+
enumStrings: {{ enumStrings }}
+
enumAlias: {{ enumAlias }}
+
diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.stories.ts new file mode 100644 index 000000000000..9e7317f3ffce --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.stories.ts @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { + EnumNumeric, + EnumNumericInitial, + EnumStringValues, + EnumsComponent, +} from './enums.component'; + +const meta: Meta = { + // title: 'Basics / Component / With Enum Types', + component: EnumsComponent, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + unionType: 'Union A', + aliasedUnionType: 'Type Alias 1', + enumNumeric: EnumNumeric.FIRST, + enumNumericInitial: EnumNumericInitial.UNO, + enumStrings: EnumStringValues.PRIMARY, + enumAlias: EnumNumeric.FIRST, + }, +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.ts new file mode 100644 index 000000000000..171e4ff51d0e --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.ts @@ -0,0 +1,50 @@ +import { Component, Input } from '@angular/core'; + +/** This component is used for testing the various forms of enum types */ +@Component({ + standalone: false, + selector: 'app-enums', + templateUrl: './enums.component.html', +}) +export class EnumsComponent { + /** Union Type of string literals */ + @Input() unionType?: 'Union A' | 'Union B' | 'Union C'; + + /** Union Type assigned as a Type Alias */ + @Input() aliasedUnionType?: TypeAlias; + + /** Base Enum Type with no assigned values */ + @Input() enumNumeric?: EnumNumeric; + + /** Enum with initial numeric value and auto-incrementing subsequent values */ + @Input() enumNumericInitial?: EnumNumericInitial; + + /** Enum with string values */ + @Input() enumStrings?: EnumStringValues; + + /** Type Aliased Enum Type */ + @Input() enumAlias?: EnumAlias; +} + +/** Button Priority */ +export enum EnumNumeric { + FIRST, + SECOND, + THIRD, +} + +export enum EnumNumericInitial { + UNO = 1, + DOS, + TRES, +} + +export enum EnumStringValues { + PRIMARY = 'PRIMARY', + SECONDARY = 'SECONDARY', + TERTIARY = 'TERTIARY', +} + +export type EnumAlias = EnumNumeric; + +type TypeAlias = 'Type Alias 1' | 'Type Alias 2' | 'Type Alias 3'; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.component.ts new file mode 100644 index 000000000000..b1d8111cef2a --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.component.ts @@ -0,0 +1,11 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + standalone: false, + selector: `storybook-base-button`, + template: ` `, +}) +export class BaseButtonComponent { + @Input() + label?: string; +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.stories.ts new file mode 100644 index 000000000000..e01e88953876 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.stories.ts @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { BaseButtonComponent } from './base-button.component'; + +const meta: Meta = { + // title: 'Basics / Component / With Inheritance', + component: BaseButtonComponent, +}; + +export default meta; + +export const BaseButton: StoryObj = { + args: { + label: 'this is label', + }, +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.component.ts new file mode 100644 index 000000000000..4dfc63e09c8a --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.component.ts @@ -0,0 +1,12 @@ +import { Component, Input } from '@angular/core'; +import { BaseButtonComponent } from './base-button.component'; + +@Component({ + standalone: false, + selector: `storybook-icon-button`, + template: ` `, +}) +export class IconButtonComponent extends BaseButtonComponent { + @Input() + icon?: string; +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.stories.ts new file mode 100644 index 000000000000..2af080a822de --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.stories.ts @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { IconButtonComponent } from './icon-button.component'; + +const meta: Meta = { + // title: 'Basics / Component / With Inheritance', + component: IconButtonComponent, +}; + +export default meta; + +type Story = StoryObj; + +export const IconButton: Story = { + args: { + icon: 'this is icon', + label: 'this is label', + }, +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-about-parent.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-about-parent.stories.ts new file mode 100644 index 000000000000..6bb6b33ae42c --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-about-parent.stories.ts @@ -0,0 +1,60 @@ +import { Component, Input } from '@angular/core'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { componentWrapperDecorator } from '@storybook/angular'; + +@Component({ + standalone: false, + selector: 'sb-button', + template: ``, + styles: [ + ` + button { + padding: 4px; + } + `, + ], +}) +class SbButtonComponent { + @Input() + color = '#5eadf5'; +} + +const meta: Meta = { + // title: 'Basics / Component / With ng-content / Button with different contents', + // Implicitly declares the component to Angular + // This will be the component described by the addon docs + component: SbButtonComponent, + decorators: [ + // Wrap all stories with this template + componentWrapperDecorator( + (story) => `${story}`, + + ({ args }) => ({ propsColor: args['color'] }) + ), + ], + argTypes: { + color: { control: 'color' }, + }, +} as Meta; + +export default meta; + +type Story = StoryObj; + +// By default storybook uses the default export component if no template or component is defined in the story +// So Storybook nests the component twice because it is first added by the componentWrapperDecorator. +export const AlwaysDefineTemplateOrComponent: Story = {}; + +export const EmptyButton: Story = { + render: () => ({ + template: '', + }), +}; + +export const InH1: Story = { + render: () => ({ + template: 'My button in h1', + }), + decorators: [componentWrapperDecorator((story) => `

${story}

`)], + name: 'In

', +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-simple.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-simple.stories.ts new file mode 100644 index 000000000000..5cbdf30403b8 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-simple.stories.ts @@ -0,0 +1,38 @@ +import { Component } from '@angular/core'; + +import type { Meta, StoryObj } from '@storybook/angular'; + +@Component({ + standalone: false, + selector: 'storybook-with-ng-content', + template: `Content value: +
`, +}) +class WithNgContentComponent {} + +const meta: Meta = { + // title: 'Basics / Component / With ng-content / Simple', + component: WithNgContentComponent, +} as Meta; + +export default meta; + +type Story = StoryObj; + +export const OnlyComponent: Story = {}; + +export const Default: Story = { + render: () => ({ + template: `

This is rendered in ng-content

`, + }), +}; + +export const WithDynamicContentAndArgs: Story = { + render: (args) => ({ + template: `

${args['content']}

`, + }), + args: { content: 'Default content' }, + argTypes: { + content: { control: 'text' }, + }, +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-ng-on-destroy/component-with-on-destroy.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-ng-on-destroy/component-with-on-destroy.stories.ts new file mode 100644 index 000000000000..a678962eaa0c --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-ng-on-destroy/component-with-on-destroy.stories.ts @@ -0,0 +1,45 @@ +import type { OnDestroy, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; +import type { Meta, StoryObj } from '@storybook/angular'; + +@Component({ + standalone: false, + selector: 'on-destroy', + template: `Current time: {{ time }}
+📝 The current time in console should no longer display after a change of story`, +}) +class OnDestroyComponent implements OnInit, OnDestroy { + time?: string; + + interval: any; + + ngOnInit(): void { + const myTimer = () => { + const d = new Date(); + this.time = d.toLocaleTimeString(); + console.info(`Current time: ${this.time}`); + }; + + myTimer(); + this.interval = setInterval(myTimer, 3000); + } + + ngOnDestroy(): void { + clearInterval(this.interval); + } +} + +const meta: Meta = { + // title: 'Basics / Component / with ngOnDestroy', + component: OnDestroyComponent, + parameters: { + // disabled due to new Date() + chromatic: { disableSnapshot: true }, + }, +} as Meta; + +export default meta; + +type Story = StoryObj; + +export const SimpleComponent: Story = {}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push-box.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push-box.component.ts new file mode 100644 index 000000000000..9d8a86aa67fb --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push-box.component.ts @@ -0,0 +1,22 @@ +import { Component, Input, ChangeDetectionStrategy, HostBinding } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-on-push-box', + template: ` Word of the day: {{ word }} `, + styles: [ + ` + :host { + display: block; + padding: 1rem; + width: fit-content; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OnPushBoxComponent { + @Input() word?: string; + + @Input() @HostBinding('style.background-color') bgColor?: string; +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push.stories.ts new file mode 100644 index 000000000000..004dfec166c6 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push.stories.ts @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { OnPushBoxComponent } from './on-push-box.component'; + +const meta: Meta = { + // title: 'Basics / Component / With OnPush strategy', + component: OnPushBoxComponent, + argTypes: { + word: { control: 'text' }, + bgColor: { control: 'color' }, + }, + args: { + word: 'The text', + bgColor: '#FFF000', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const ClassSpecifiedComponentWithOnPushAndArgs: Story = { + name: 'Class-specified component with OnPush and Args', +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom-pipes.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom-pipes.stories.ts new file mode 100644 index 000000000000..f6b4ff6e8121 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom-pipes.stories.ts @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { moduleMetadata } from '@storybook/angular'; + +import { CustomPipePipe } from './custom.pipe'; +import { WithPipeComponent } from './with-pipe.component'; + +const meta: Meta = { + // title: 'Basics / Component / With Pipes', + component: WithPipeComponent, + decorators: [ + moduleMetadata({ + declarations: [CustomPipePipe], + }), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Simple: Story = { + render: () => ({ + props: { + field: 'foobar', + }, + }), +}; + +export const WithArgsStory: Story = { + name: 'With args', + argTypes: { + field: { control: 'text' }, + }, + args: { + field: 'Foo Bar', + }, +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom.pipe.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom.pipe.ts new file mode 100644 index 000000000000..d8865dc3af2e --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom.pipe.ts @@ -0,0 +1,12 @@ +import type { PipeTransform } from '@angular/core'; +import { Pipe } from '@angular/core'; + +@Pipe({ + standalone: false, + name: 'customPipe', +}) +export class CustomPipePipe implements PipeTransform { + transform(value: any, args?: any): any { + return `CustomPipe: ${value}`; + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/with-pipe.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/with-pipe.component.ts new file mode 100644 index 000000000000..12ec82171649 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/with-pipe.component.ts @@ -0,0 +1,11 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-with-pipe', + template: `

{{ field | customPipe }}

`, +}) +export class WithPipeComponent { + @Input() + field: any; +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.html b/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.html new file mode 100644 index 000000000000..36768a998934 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.html @@ -0,0 +1,7 @@ +
+
All dependencies are defined: {{ isAllDeps() }}
+
Title: {{ title }}
+
Injector: {{ injector.constructor.toString() }}
+
ElementRef: {{ elRefStr() }}
+
TestToken: {{ testToken }}
+
diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.stories.ts new file mode 100644 index 000000000000..2226932cf759 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.stories.ts @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { DiComponent } from './di.component'; + +const meta: Meta = { + // title: 'Basics / Component / With Provider', + component: DiComponent, +}; + +export default meta; + +type Story = StoryObj; + +export const InputsAndInjectDependencies: Story = { + render: () => ({ + props: { + title: 'Component dependencies', + }, + }), + name: 'inputs and inject dependencies', +}; + +export const InputsAndInjectDependenciesWithArgs: Story = { + name: 'inputs and inject dependencies with args', + argTypes: { + title: { control: 'text' }, + }, + args: { + title: 'Component dependencies', + }, +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.ts new file mode 100644 index 000000000000..a07ade4696ff --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.ts @@ -0,0 +1,33 @@ +// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. +// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". +// Do not remove `Inject` even though it seems unused, it is used in the constructor. +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { Injector, ElementRef, Component, Input, InjectionToken, Inject } from '@angular/core'; +import { stringify } from 'telejson'; + +export const TEST_TOKEN = new InjectionToken('test'); + +@Component({ + standalone: false, + selector: 'storybook-di-component', + templateUrl: './di.component.html', + providers: [{ provide: TEST_TOKEN, useValue: 123 }], +}) +export class DiComponent { + @Input() + title?: string; + + constructor( + protected injector: Injector, + protected elRef: ElementRef, + @Inject(TEST_TOKEN) protected testToken: number + ) {} + + isAllDeps(): boolean { + return Boolean(this.testToken && this.elRef && this.injector && this.title); + } + + elRefStr(): string { + return stringify(this.elRef, { maxDepth: 1 }); + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.css b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.css new file mode 100644 index 000000000000..fdfe0940158f --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.css @@ -0,0 +1,3 @@ +.red-color { + color: red; +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.html b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.html new file mode 100644 index 000000000000..129e735ec5b0 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.html @@ -0,0 +1,5 @@ +
+

Styled with scoped CSS

+

Styled with scoped SCSS

+

Styled with global CSS

+
diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.scss b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.scss new file mode 100644 index 000000000000..5895f510a1da --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.scss @@ -0,0 +1,5 @@ +div { + p.blue-color { + color: blue; + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.stories.ts new file mode 100644 index 000000000000..424feafc049a --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.stories.ts @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { StyledComponent } from './styled.component'; + +const meta: Meta = { + // title: 'Basics / Component / With StyleUrls', + component: StyledComponent, +}; + +export default meta; + +type Story = StoryObj; + +export const ComponentWithStyles: Story = { + name: 'Component with styles', +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.ts new file mode 100644 index 000000000000..63ac3c56b07e --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-styled-component', + templateUrl: './styled.component.html', + styleUrls: ['./styled.component.css', './styled.component.scss'], +}) +export class StyledComponent {} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-template/template.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-template/template.component.ts new file mode 100644 index 000000000000..8951a4532793 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-template/template.component.ts @@ -0,0 +1,27 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-template', + imports: [CommonModule], + template: `
+ Label: {{ label }} +
+ Label2: {{ label2 }} +
+ +
`, + styles: [], + standalone: true, +}) +export class Template { + @Input() label = 'default label'; + + @Input() label2 = 'default label2'; + + @Output() changed = new EventEmitter(); + + inc() { + this.changed.emit('Increase'); + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-template/template.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-template/template.stories.ts new file mode 100644 index 000000000000..9b9dcf72a554 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-template/template.stories.ts @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { argsToTemplate } from '@storybook/angular'; + +import { Template } from './template.component'; + +const meta: Meta