From 5905c2946e00da6eae658b02af94404c3d4fba89 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Sat, 23 Sep 2023 08:41:34 -0400 Subject: [PATCH] feat(vue): storybook configuration generator for vue --- docs/generated/manifests/menus.json | 24 ++ docs/generated/manifests/nx.json | 30 ++ docs/generated/manifests/tags.json | 7 + .../packages/storybook/documents/overview.md | 82 +++--- .../storybook/generators/configuration.json | 2 +- docs/map.json | 7 + .../packages/storybook/plugin-overview.md | 82 +++--- docs/shared/recipes/storybook/plugin-vue.md | 80 ++++++ docs/shared/reference/sitemap.md | 1 + e2e/vue/src/vue-storybook.test.ts | 36 +++ .../docs/configuration-generator-examples.md | 6 +- .../generators/configuration/configuration.ts | 7 +- .../configuration/lib/util-functions.ts | 6 +- .../storybook/src/generators/init/init.ts | 9 + packages/vue/.eslintrc.json | 3 +- packages/vue/docs/stories-examples.md | 38 +++ .../docs/storybook-configuration-examples.md | 55 ++++ packages/vue/generators.json | 12 + packages/vue/package.json | 1 + .../generators/component/component.spec.ts | 21 -- .../vue/src/generators/component/component.ts | 4 +- .../__snapshots__/stories.app.spec.ts.snap | 108 +++++++ .../__snapshots__/stories.lib.spec.ts.snap | 103 +++++++ .../component-story.spec.ts.snap | 115 ++++++++ .../stories/lib/component-story.spec.ts | 141 +++++++++ .../generators/stories/lib/component-story.ts | 60 ++++ .../__componentFileName__.stories.js__tmpl__ | 25 ++ .../__componentFileName__.stories.ts__tmpl__ | 32 +++ .../vue/src/generators/stories/lib/utils.ts | 76 +++++ .../vue/src/generators/stories/schema.json | 64 +++++ .../generators/stories/stories.app.spec.ts | 272 ++++++++++++++++++ .../generators/stories/stories.lib.spec.ts | 201 +++++++++++++ .../vue/src/generators/stories/stories.ts | 106 +++++++ .../__snapshots__/configuration.spec.ts.snap | 33 +++ .../configuration.spec.ts | 165 +++++++++++ .../storybook-configuration/configuration.ts | 52 ++++ .../storybook-configuration/schema.d.ts | 12 + .../storybook-configuration/schema.json | 77 +++++ 38 files changed, 2038 insertions(+), 117 deletions(-) create mode 100644 docs/shared/recipes/storybook/plugin-vue.md create mode 100644 e2e/vue/src/vue-storybook.test.ts create mode 100644 packages/vue/docs/stories-examples.md create mode 100644 packages/vue/docs/storybook-configuration-examples.md create mode 100644 packages/vue/src/generators/stories/__snapshots__/stories.app.spec.ts.snap create mode 100644 packages/vue/src/generators/stories/__snapshots__/stories.lib.spec.ts.snap create mode 100644 packages/vue/src/generators/stories/lib/__snapshots__/component-story.spec.ts.snap create mode 100644 packages/vue/src/generators/stories/lib/component-story.spec.ts create mode 100644 packages/vue/src/generators/stories/lib/component-story.ts create mode 100644 packages/vue/src/generators/stories/lib/files/js/__componentFileName__.stories.js__tmpl__ create mode 100644 packages/vue/src/generators/stories/lib/files/ts/__componentFileName__.stories.ts__tmpl__ create mode 100644 packages/vue/src/generators/stories/lib/utils.ts create mode 100644 packages/vue/src/generators/stories/schema.json create mode 100644 packages/vue/src/generators/stories/stories.app.spec.ts create mode 100644 packages/vue/src/generators/stories/stories.lib.spec.ts create mode 100644 packages/vue/src/generators/stories/stories.ts create mode 100644 packages/vue/src/generators/storybook-configuration/__snapshots__/configuration.spec.ts.snap create mode 100644 packages/vue/src/generators/storybook-configuration/configuration.spec.ts create mode 100644 packages/vue/src/generators/storybook-configuration/configuration.ts create mode 100644 packages/vue/src/generators/storybook-configuration/schema.d.ts create mode 100644 packages/vue/src/generators/storybook-configuration/schema.json diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index b86887fac6c8b..de0b0b0f6d7f2 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -1711,6 +1711,14 @@ "children": [], "disableCollapsible": false }, + { + "name": "Set up Storybook for Vue Projects", + "path": "/recipes/storybook/overview-vue", + "id": "overview-vue", + "isExternal": false, + "children": [], + "disableCollapsible": false + }, { "name": "Set up Storybook for Angular Projects", "path": "/recipes/storybook/overview-angular", @@ -2900,6 +2908,14 @@ "children": [], "disableCollapsible": false }, + { + "name": "Set up Storybook for Vue Projects", + "path": "/recipes/storybook/overview-vue", + "id": "overview-vue", + "isExternal": false, + "children": [], + "disableCollapsible": false + }, { "name": "Set up Storybook for Angular Projects", "path": "/recipes/storybook/overview-angular", @@ -2991,6 +3007,14 @@ "children": [], "disableCollapsible": false }, + { + "name": "Set up Storybook for Vue Projects", + "path": "/recipes/storybook/overview-vue", + "id": "overview-vue", + "isExternal": false, + "children": [], + "disableCollapsible": false + }, { "name": "Set up Storybook for Angular Projects", "path": "/recipes/storybook/overview-angular", diff --git a/docs/generated/manifests/nx.json b/docs/generated/manifests/nx.json index 680cd8dbafc0c..9de4e55ab49a6 100644 --- a/docs/generated/manifests/nx.json +++ b/docs/generated/manifests/nx.json @@ -2133,6 +2133,16 @@ "path": "/recipes/storybook/overview-react", "tags": ["storybook"] }, + { + "id": "overview-vue", + "name": "Set up Storybook for Vue Projects", + "description": "This guide explains how to set up Storybook for Vue projects in your Nx workspace.", + "file": "shared/recipes/storybook/plugin-vue", + "itemList": [], + "isExternal": false, + "path": "/recipes/storybook/overview-vue", + "tags": ["storybook"] + }, { "id": "overview-angular", "name": "Set up Storybook for Angular Projects", @@ -3616,6 +3626,16 @@ "path": "/recipes/storybook/overview-react", "tags": ["storybook"] }, + { + "id": "overview-vue", + "name": "Set up Storybook for Vue Projects", + "description": "This guide explains how to set up Storybook for Vue projects in your Nx workspace.", + "file": "shared/recipes/storybook/plugin-vue", + "itemList": [], + "isExternal": false, + "path": "/recipes/storybook/overview-vue", + "tags": ["storybook"] + }, { "id": "overview-angular", "name": "Set up Storybook for Angular Projects", @@ -3731,6 +3751,16 @@ "path": "/recipes/storybook/overview-react", "tags": ["storybook"] }, + "/recipes/storybook/overview-vue": { + "id": "overview-vue", + "name": "Set up Storybook for Vue Projects", + "description": "This guide explains how to set up Storybook for Vue projects in your Nx workspace.", + "file": "shared/recipes/storybook/plugin-vue", + "itemList": [], + "isExternal": false, + "path": "/recipes/storybook/overview-vue", + "tags": ["storybook"] + }, "/recipes/storybook/overview-angular": { "id": "overview-angular", "name": "Set up Storybook for Angular Projects", diff --git a/docs/generated/manifests/tags.json b/docs/generated/manifests/tags.json index e57578dac14d4..6ff0bc373bf3d 100644 --- a/docs/generated/manifests/tags.json +++ b/docs/generated/manifests/tags.json @@ -892,6 +892,13 @@ "name": "Set up Storybook for React Projects", "path": "/recipes/storybook/overview-react" }, + { + "description": "This guide explains how to set up Storybook for Vue projects in your Nx workspace.", + "file": "shared/recipes/storybook/plugin-vue", + "id": "overview-vue", + "name": "Set up Storybook for Vue Projects", + "path": "/recipes/storybook/overview-vue" + }, { "description": "This guide explains how to set up Storybook for Angular projects in your Nx workspace.", "file": "shared/recipes/storybook/plugin-angular", diff --git a/docs/generated/packages/storybook/documents/overview.md b/docs/generated/packages/storybook/documents/overview.md index 5ba8376857f7a..0b939e93d2811 100644 --- a/docs/generated/packages/storybook/documents/overview.md +++ b/docs/generated/packages/storybook/documents/overview.md @@ -53,36 +53,7 @@ You can generate Storybook configuration for an individual project with this com nx g @nx/storybook:configuration project-name ``` -If you are NOT using a framework-specific generator (for [Angular](/nx-api/angular/generators/storybook-configuration), [React](/nx-api/react/generators/storybook-configuration), [React Native](/nx-api/react-native/generators/storybook-configuration)), in the field `uiFramework` you must choose one of the following Storybook frameworks: - -- `@storybook/angular` -- `@storybook/html-webpack5` -- `@storybook/nextjs` -- `@storybook/preact-webpack5` -- `@storybook/react-webpack5` -- `@storybook/react-vite` -- `@storybook/server-webpack5` -- `@storybook/svelte-webpack5` -- `@storybook/svelte-vite` -- `@storybook/sveltekit` -- `@storybook/vue-webpack5` -- `@storybook/vue-vite` -- `@storybook/vue3-webpack5` -- `@storybook/vue3-vite` -- `@storybook/web-components-webpack5` -- `@storybook/web-components-vite` - -Choosing one of these frameworks will have the following effects on your workspace: - -1. Nx will install all the required Storybook packages that go with it. - -2. Nx will generate a project-level `.storybook` folder (located under `libs/your-project/.storybook` or `apps/your-project/.storybook`) containing the essential configuration files for Storybook. - -3. Nx will create new `targets` in your project's `project.json`, called `storybook` and `build-storybook`, containing all the necessary configuration to serve and build Storybook. - -4. Nx will generate a new Cypress e2e app for your project (if there isn't one already) to run against the Storybook instance. - -Make sure to **use the framework-specific generators** if your project is using Angular, React, Next.js or React Native: [`@nx/angular:storybook-configuration`](/nx-api/angular/generators/storybook-configuration), [`@nx/react:storybook-configuration`](/nx-api/react/generators/storybook-configuration), [`@nx/react-native:storybook-configuration`](/nx-api/react-native/generators/storybook-configuration): +or {% tabs %} {% tab label="Angular" %} @@ -98,6 +69,13 @@ nx g @nx/angular:storybook-configuration my-angular-project nx g @nx/react:storybook-configuration my-react-project ``` +{% /tab %} +{% tab label="Vue" %} + +```shell +nx g @nx/vue:storybook-configuration my-vue-project +``` + {% /tab %} {% tab label="React Native" %} @@ -110,15 +88,36 @@ nx g @nx/react-native:storybook-configuration my-react-native-project These framework-specific generators will also **generate stories** for you. -### Configure your project using TypeScript +If you are NOT using a framework-specific generator (for [Angular](/nx-api/angular/generators/storybook-configuration), [React](/nx-api/react/generators/storybook-configuration), [React Native](/nx-api/react-native/generators/storybook-configuration), [Vue](/nx-api/vue/generators/storybook-configuration)), in the field `uiFramework` you must choose one of the following Storybook frameworks: + +- `@storybook/angular` +- `@storybook/html-webpack5` +- `@storybook/nextjs` +- `@storybook/preact-webpack5` +- `@storybook/react-webpack5` +- `@storybook/react-vite` +- `@storybook/server-webpack5` +- `@storybook/svelte-webpack5` +- `@storybook/svelte-vite` +- `@storybook/sveltekit` +- `@storybook/vue-webpack5` +- `@storybook/vue-vite` +- `@storybook/vue3-webpack5` +- `@storybook/vue3-vite` +- `@storybook/web-components-webpack5` +- `@storybook/web-components-vite` + +Choosing one of these frameworks will have the following effects on your workspace: -You can choose to configure your project using TypeScript instead of JavaScript. To do that, just add the `--tsConfiguration=true` flag to the above command, like this: +1. Nx will install all the required Storybook packages that go with it. -```shell -nx g @nx/storybook:configuration project-name --tsConfiguration=true -``` +2. Nx will generate a project-level `.storybook` folder (located under `libs/your-project/.storybook` or `apps/your-project/.storybook`) containing the essential configuration files for Storybook. + +3. Nx will create new `targets` in your project's `project.json`, called `storybook` and `build-storybook`, containing all the necessary configuration to serve and build Storybook. + +4. Nx will generate a new Cypress e2e app for your project (if there isn't one already) to run against the Storybook instance. -[Here is the Storybook documentation](https://storybook.js.org/docs/react/configure/overview#configure-your-project-with-typescript) if you want to learn more about configuring your project with TypeScript. +Make sure to **use the framework-specific generators** if your project is using Angular, React, Next.js or React Native: [`@nx/angular:storybook-configuration`](/nx-api/angular/generators/storybook-configuration), [`@nx/react:storybook-configuration`](/nx-api/react/generators/storybook-configuration), [`@nx/react-native:storybook-configuration`](/nx-api/react-native/generators/storybook-configuration), as shown above. ### Running Storybook @@ -157,12 +156,12 @@ The project-specific Storybook configuration is pretty much similar to what you ```text / ├── .storybook/ -│ ├── main.js -│ ├── preview.js -│ ├── tsconfig.json +│ ├── main.ts +│ └── preview.ts ├── src/ ├── README.md ├── tsconfig.json +├── tsconfig.storybook.json └── etc... ``` @@ -170,7 +169,7 @@ The project-specific Storybook configuration is pretty much similar to what you To register a [Storybook addon](https://storybook.js.org/addons/) for all Storybook instances in your workspace: -1. In your project's `.storybook/main.js` file, in the `addons` array of the `module.exports` object, add the new addon: +1. In your project's `.storybook/main.ts` file, in the `addons` array of the `module.exports` object, add the new addon: ```typescript {% fileName="/.storybook/main.js" %} module.exports = { @@ -180,7 +179,7 @@ To register a [Storybook addon](https://storybook.js.org/addons/) for all Storyb }; ``` -2. If a decorator is required, in each project's `/.storybook/preview.js`, you can export an array called `decorators`. +2. If a decorator is required, in each project's `/.storybook/preview.ts`, you can export an array called `decorators`. ```typescript {% fileName="/.storybook/preview.js" %} import someDecorator from 'some-storybook-addon'; @@ -199,6 +198,7 @@ You can find dedicated information for React and Angular: - [Set up Storybook for Angular Projects](/recipes/storybook/overview-angular) - [Set up Storybook for React Projects](/recipes/storybook/overview-react) +- [Set up Storybook for Vue Projects](/recipes/storybook/overview-vue) You can find all Storybook-related Nx documentation in the [Storybook recipes section](/recipes/storybook). diff --git a/docs/generated/packages/storybook/generators/configuration.json b/docs/generated/packages/storybook/generators/configuration.json index c36a985add65f..727a01236570c 100644 --- a/docs/generated/packages/storybook/generators/configuration.json +++ b/docs/generated/packages/storybook/generators/configuration.json @@ -104,7 +104,7 @@ } }, "required": ["name", "uiFramework"], - "examplesFile": "---\ntitle: Storybook configuration generator examples\ndescription: This page contains examples for the @nx/storybook:configuration generator.\n---\n\nThis is a framework-agnostic generator for setting up Storybook configuration for a project.\n\n```bash\nnx g @nx/storybook:configuration\n```\n\nStarting Nx 16, Nx does not support Storybook v6 any more. So, Nx will configure your project to use Storybook v7. If you are not on Storybook 7 yet, please migrate. You can read more about how to migrate to Storybook 7 in our [Storybook 7 migration generator](/packages/storybook/generators/migrate-7) guide.\n\nWhen running this generator, you will be prompted to provide the following:\n\n- The `name` of the project you want to generate the configuration for.\n- The `uiFramework` you want to use. Supported values are:\n - `@storybook/angular`\n - `@storybook/html-webpack5`\n - `@storybook/nextjs`\n - `@storybook/preact-webpack5`\n - `@storybook/react-webpack5`\n - `@storybook/react-vite`\n - `@storybook/server-webpack5`\n - `@storybook/svelte-webpack5`\n - `@storybook/svelte-vite`\n - `@storybook/sveltekit`\n - `@storybook/vue-webpack5`\n - `@storybook/vue-vite`\n - `@storybook/vue3-webpack5`\n - `@storybook/vue3-vite`\n - `@storybook/web-components-webpack5`\n - `@storybook/web-components-vite`\n- Whether you want to set up [Storybook interaction tests](https://storybook.js.org/docs/angular/writing-tests/interaction-testing) (`interactionTests`). If you choose `yes`, all the necessary dependencies will be installed. Also, a `test-storybook` target will be generated in your project's `project.json`, with a command to invoke the [Storybook `test-runner`](https://storybook.js.org/docs/angular/writing-tests/test-runner). You can read more about this in the [Nx Storybook interaction tests documentation page](/packages/storybook/documents/interaction-tests).\n\nYou must provide a `name` and a `uiFramework` for the generator to work.\n\nYou can read more about how this generator works, in the [Storybook package overview page](/packages/storybook#generating-storybook-configuration).\n\nIf you are using Angular, React, React Native or Next.js in your project, it's best to use the framework specific generator:\n\n- [React Storybook Configuration Generator](/packages/react/generators/storybook-configuration) (React and Next.js projects)\n\n- [Angular Storybook Configuration Generator](/packages/angular/generators/storybook-configuration)\n\n- [React Native Storybook Configuration Generator](/packages/react-native/generators/storybook-configuration)\n\n## Examples\n\n### Generate Storybook configuration using JavaScript\n\n```bash\nnx g @nx/storybook:configuration ui --uiFramework=@storybook/web-components-vite --tsConfiguration=false\n```\n\nBy default, our generator generates TypeScript Storybook configuration files. You can choose to use JavaScript for the Storybook configuration files of your project (the files inside the `.storybook` directory, eg. `.storybook/main.js`).\n", + "examplesFile": "---\ntitle: Storybook configuration generator examples\ndescription: This page contains examples for the @nx/storybook:configuration generator.\n---\n\nThis is a framework-agnostic generator for setting up Storybook configuration for a project.\n\n```bash\nnx g @nx/storybook:configuration\n```\n\nStarting Nx 16, Nx does not support Storybook v6 any more. So, Nx will configure your project to use Storybook v7. If you are not on Storybook 7 yet, please migrate. You can read more about how to migrate to Storybook 7 in our [Storybook 7 migration generator](/packages/storybook/generators/migrate-7) guide.\n\nWhen running this generator, you will be prompted to provide the following:\n\n- The `name` of the project you want to generate the configuration for.\n- The `uiFramework` you want to use. Supported values are:\n - `@storybook/angular`\n - `@storybook/html-webpack5`\n - `@storybook/nextjs`\n - `@storybook/preact-webpack5`\n - `@storybook/react-webpack5`\n - `@storybook/react-vite`\n - `@storybook/server-webpack5`\n - `@storybook/svelte-webpack5`\n - `@storybook/svelte-vite`\n - `@storybook/sveltekit`\n - `@storybook/vue-webpack5`\n - `@storybook/vue-vite`\n - `@storybook/vue3-webpack5`\n - `@storybook/vue3-vite`\n - `@storybook/web-components-webpack5`\n - `@storybook/web-components-vite`\n- Whether you want to set up [Storybook interaction tests](https://storybook.js.org/docs/angular/writing-tests/interaction-testing) (`interactionTests`). If you choose `yes`, all the necessary dependencies will be installed. Also, a `test-storybook` target will be generated in your project's `project.json`, with a command to invoke the [Storybook `test-runner`](https://storybook.js.org/docs/angular/writing-tests/test-runner). You can read more about this in the [Nx Storybook interaction tests documentation page](/packages/storybook/documents/interaction-tests).\n\nYou must provide a `name` and a `uiFramework` for the generator to work.\n\nYou can read more about how this generator works, in the [Storybook package overview page](/packages/storybook#generating-storybook-configuration).\n\nIf you are using Angular, React, React Native or Next.js in your project, it's best to use the framework specific generator:\n\n- [React Storybook Configuration Generator](/nx-api/react/generators/storybook-configuration) (React and Next.js projects)\n\n- [Angular Storybook Configuration Generator](/nx-api/angular/generators/storybook-configuration)\n\n- [React Native Storybook Configuration Generator](/nx-api/react-native/generators/storybook-configuration)\n\n## Examples\n\n### Generate Storybook configuration using JavaScript\n\n```bash\nnx g @nx/storybook:configuration ui --uiFramework=@storybook/web-components-vite --tsConfiguration=false\n```\n\nBy default, our generator generates TypeScript Storybook configuration files. You can choose to use JavaScript for the Storybook configuration files of your project (the files inside the `.storybook` directory, eg. `.storybook/main.js`).\n", "presets": [] }, "description": "Add Storybook configuration to a UI library or an application.", diff --git a/docs/map.json b/docs/map.json index 0edcf9908297c..b2afd9034fe51 100644 --- a/docs/map.json +++ b/docs/map.json @@ -656,6 +656,13 @@ "description": "This guide explains how to set up Storybook for React projects in your Nx workspace.", "file": "shared/recipes/storybook/plugin-react" }, + { + "name": "Set up Storybook for Vue Projects", + "id": "overview-vue", + "tags": ["storybook"], + "description": "This guide explains how to set up Storybook for Vue projects in your Nx workspace.", + "file": "shared/recipes/storybook/plugin-vue" + }, { "name": "Set up Storybook for Angular Projects", "id": "overview-angular", diff --git a/docs/shared/packages/storybook/plugin-overview.md b/docs/shared/packages/storybook/plugin-overview.md index 5ba8376857f7a..0b939e93d2811 100644 --- a/docs/shared/packages/storybook/plugin-overview.md +++ b/docs/shared/packages/storybook/plugin-overview.md @@ -53,36 +53,7 @@ You can generate Storybook configuration for an individual project with this com nx g @nx/storybook:configuration project-name ``` -If you are NOT using a framework-specific generator (for [Angular](/nx-api/angular/generators/storybook-configuration), [React](/nx-api/react/generators/storybook-configuration), [React Native](/nx-api/react-native/generators/storybook-configuration)), in the field `uiFramework` you must choose one of the following Storybook frameworks: - -- `@storybook/angular` -- `@storybook/html-webpack5` -- `@storybook/nextjs` -- `@storybook/preact-webpack5` -- `@storybook/react-webpack5` -- `@storybook/react-vite` -- `@storybook/server-webpack5` -- `@storybook/svelte-webpack5` -- `@storybook/svelte-vite` -- `@storybook/sveltekit` -- `@storybook/vue-webpack5` -- `@storybook/vue-vite` -- `@storybook/vue3-webpack5` -- `@storybook/vue3-vite` -- `@storybook/web-components-webpack5` -- `@storybook/web-components-vite` - -Choosing one of these frameworks will have the following effects on your workspace: - -1. Nx will install all the required Storybook packages that go with it. - -2. Nx will generate a project-level `.storybook` folder (located under `libs/your-project/.storybook` or `apps/your-project/.storybook`) containing the essential configuration files for Storybook. - -3. Nx will create new `targets` in your project's `project.json`, called `storybook` and `build-storybook`, containing all the necessary configuration to serve and build Storybook. - -4. Nx will generate a new Cypress e2e app for your project (if there isn't one already) to run against the Storybook instance. - -Make sure to **use the framework-specific generators** if your project is using Angular, React, Next.js or React Native: [`@nx/angular:storybook-configuration`](/nx-api/angular/generators/storybook-configuration), [`@nx/react:storybook-configuration`](/nx-api/react/generators/storybook-configuration), [`@nx/react-native:storybook-configuration`](/nx-api/react-native/generators/storybook-configuration): +or {% tabs %} {% tab label="Angular" %} @@ -98,6 +69,13 @@ nx g @nx/angular:storybook-configuration my-angular-project nx g @nx/react:storybook-configuration my-react-project ``` +{% /tab %} +{% tab label="Vue" %} + +```shell +nx g @nx/vue:storybook-configuration my-vue-project +``` + {% /tab %} {% tab label="React Native" %} @@ -110,15 +88,36 @@ nx g @nx/react-native:storybook-configuration my-react-native-project These framework-specific generators will also **generate stories** for you. -### Configure your project using TypeScript +If you are NOT using a framework-specific generator (for [Angular](/nx-api/angular/generators/storybook-configuration), [React](/nx-api/react/generators/storybook-configuration), [React Native](/nx-api/react-native/generators/storybook-configuration), [Vue](/nx-api/vue/generators/storybook-configuration)), in the field `uiFramework` you must choose one of the following Storybook frameworks: + +- `@storybook/angular` +- `@storybook/html-webpack5` +- `@storybook/nextjs` +- `@storybook/preact-webpack5` +- `@storybook/react-webpack5` +- `@storybook/react-vite` +- `@storybook/server-webpack5` +- `@storybook/svelte-webpack5` +- `@storybook/svelte-vite` +- `@storybook/sveltekit` +- `@storybook/vue-webpack5` +- `@storybook/vue-vite` +- `@storybook/vue3-webpack5` +- `@storybook/vue3-vite` +- `@storybook/web-components-webpack5` +- `@storybook/web-components-vite` + +Choosing one of these frameworks will have the following effects on your workspace: -You can choose to configure your project using TypeScript instead of JavaScript. To do that, just add the `--tsConfiguration=true` flag to the above command, like this: +1. Nx will install all the required Storybook packages that go with it. -```shell -nx g @nx/storybook:configuration project-name --tsConfiguration=true -``` +2. Nx will generate a project-level `.storybook` folder (located under `libs/your-project/.storybook` or `apps/your-project/.storybook`) containing the essential configuration files for Storybook. + +3. Nx will create new `targets` in your project's `project.json`, called `storybook` and `build-storybook`, containing all the necessary configuration to serve and build Storybook. + +4. Nx will generate a new Cypress e2e app for your project (if there isn't one already) to run against the Storybook instance. -[Here is the Storybook documentation](https://storybook.js.org/docs/react/configure/overview#configure-your-project-with-typescript) if you want to learn more about configuring your project with TypeScript. +Make sure to **use the framework-specific generators** if your project is using Angular, React, Next.js or React Native: [`@nx/angular:storybook-configuration`](/nx-api/angular/generators/storybook-configuration), [`@nx/react:storybook-configuration`](/nx-api/react/generators/storybook-configuration), [`@nx/react-native:storybook-configuration`](/nx-api/react-native/generators/storybook-configuration), as shown above. ### Running Storybook @@ -157,12 +156,12 @@ The project-specific Storybook configuration is pretty much similar to what you ```text / ├── .storybook/ -│ ├── main.js -│ ├── preview.js -│ ├── tsconfig.json +│ ├── main.ts +│ └── preview.ts ├── src/ ├── README.md ├── tsconfig.json +├── tsconfig.storybook.json └── etc... ``` @@ -170,7 +169,7 @@ The project-specific Storybook configuration is pretty much similar to what you To register a [Storybook addon](https://storybook.js.org/addons/) for all Storybook instances in your workspace: -1. In your project's `.storybook/main.js` file, in the `addons` array of the `module.exports` object, add the new addon: +1. In your project's `.storybook/main.ts` file, in the `addons` array of the `module.exports` object, add the new addon: ```typescript {% fileName="/.storybook/main.js" %} module.exports = { @@ -180,7 +179,7 @@ To register a [Storybook addon](https://storybook.js.org/addons/) for all Storyb }; ``` -2. If a decorator is required, in each project's `/.storybook/preview.js`, you can export an array called `decorators`. +2. If a decorator is required, in each project's `/.storybook/preview.ts`, you can export an array called `decorators`. ```typescript {% fileName="/.storybook/preview.js" %} import someDecorator from 'some-storybook-addon'; @@ -199,6 +198,7 @@ You can find dedicated information for React and Angular: - [Set up Storybook for Angular Projects](/recipes/storybook/overview-angular) - [Set up Storybook for React Projects](/recipes/storybook/overview-react) +- [Set up Storybook for Vue Projects](/recipes/storybook/overview-vue) You can find all Storybook-related Nx documentation in the [Storybook recipes section](/recipes/storybook). diff --git a/docs/shared/recipes/storybook/plugin-vue.md b/docs/shared/recipes/storybook/plugin-vue.md new file mode 100644 index 0000000000000..ba8d7521a1101 --- /dev/null +++ b/docs/shared/recipes/storybook/plugin-vue.md @@ -0,0 +1,80 @@ +--- +title: Set up Storybook for Vue Projects +description: This guide explains how to set up Storybook for Vue projects in your Nx workspace. +--- + +# Set up Storybook for Vue Projects + +This guide will walk you through setting up [Storybook](https://storybook.js.org) for Vue projects in your Nx workspace. + +{% callout type="warning" title="Set up Storybook in your workspace" %} +You first need to set up Storybook for your Nx workspace, if you haven't already. You can read the [Storybook plugin overview guide](/nx-api/storybook) to get started. +{% /callout %} + +## Generate Storybook Configuration for a Vue project + +You can generate Storybook configuration for an individual Vue project by using the [`@nx/vue:storybook-configuration` generator](/nx-api/vue/generators/storybook-configuration), like this: + +```shell +nx g @nx/vue:storybook-configuration project-name +``` + +## Auto-generate Stories + +The [`@nx/vue:storybook-configuration` generator](/nx-api/vue/generators/storybook-configuration) has the option to automatically generate `*.stories.ts` files for each component declared in the library. The stories will be generated using [Component Story Format 3 (CSF3)](https://storybook.js.org/blog/storybook-csf3-is-here/). + +```text +/ +├── MyComponent.vue +└── MyComponent.stories.ts +``` + +If you add more components to your project, and want to generate stories for all your (new) components at any point, you can use the [`@nx/vue:stories` generator](/nx-api/vue/generators/stories): + +```shell +nx g @nx/vue:stories --project= +``` + +{% callout type="note" title="Example" %} +Let's take for a example a library in your workspace, under `libs/feature/ui`, called `feature-ui`. This library contains a component, called `my-button`. + +The command to generate stories for that library would be: + +```shell +nx g @nx/vue:stories --project=feature-ui +``` + +and the result would be the following: + +```text +/ +├── apps/ +├── libs/ +│ ├── feature/ +│ │ ├── ui/ +| | | ├── .storybook/ +| | | ├── src/ +| | | | ├──lib +| | | | | ├──my-button +| | | | | | ├── MyButton.vue +| | | | | | ├── MyButton.stories.ts +| | | | | | └── etc... +| | | | | └── etc... +| | | ├── README.md +| | | ├── tsconfig.json +| | | └── etc... +| | └── etc... +| └── etc... +├── nx.json +├── package.json +├── README.md +└── etc... +``` + +{% /callout %} + +## More Documentation + +You can find all Storybook-related Nx topics [here](/nx-api#storybook). + +For more on using Storybook, see the [official Storybook documentation](https://storybook.js.org/docs/vue/get-started/introduction). diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index ef4c3026bd68d..d77d28bddedca 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -112,6 +112,7 @@ - [Wait for Tasks to Finish](/recipes/node/wait-for-tasks) - [Storybook](/recipes/storybook) - [Set up Storybook for React Projects](/recipes/storybook/overview-react) + - [Set up Storybook for Vue Projects](/recipes/storybook/overview-vue) - [Set up Storybook for Angular Projects](/recipes/storybook/overview-angular) - [Configuring Storybook on Nx](/recipes/storybook/configuring-storybook) - [One main Storybook instance for all projects](/recipes/storybook/one-storybook-for-all) diff --git a/e2e/vue/src/vue-storybook.test.ts b/e2e/vue/src/vue-storybook.test.ts new file mode 100644 index 0000000000000..96836618a13eb --- /dev/null +++ b/e2e/vue/src/vue-storybook.test.ts @@ -0,0 +1,36 @@ +import { + checkFilesExist, + cleanupProject, + newProject, + runCLI, + setMaxWorkers, + uniq, +} from '@nx/e2e/utils'; +import { join } from 'path'; + +describe('Storybook generators and executors for Vue projects', () => { + const vueStorybookApp = uniq('vue-app'); + let proj; + beforeAll(async () => { + proj = newProject(); + runCLI( + `generate @nx/vue:app ${vueStorybookApp} --project-name-and-root-format=as-provided --no-interactive` + ); + setMaxWorkers(join(vueStorybookApp, 'project.json')); + runCLI( + `generate @nx/vue:storybook-configuration ${vueStorybookApp} --generateStories --no-interactive` + ); + }); + + afterAll(() => { + cleanupProject(); + }); + + describe('build storybook', () => { + it('should build a vue based storybook setup', () => { + // build + runCLI(`run ${vueStorybookApp}:build-storybook --verbose`); + checkFilesExist(`dist/storybook/${vueStorybookApp}/index.html`); + }, 300_000); + }); +}); diff --git a/packages/storybook/docs/configuration-generator-examples.md b/packages/storybook/docs/configuration-generator-examples.md index c21edeeed2618..35c0b05c6897f 100644 --- a/packages/storybook/docs/configuration-generator-examples.md +++ b/packages/storybook/docs/configuration-generator-examples.md @@ -39,11 +39,11 @@ You can read more about how this generator works, in the [Storybook package over If you are using Angular, React, React Native or Next.js in your project, it's best to use the framework specific generator: -- [React Storybook Configuration Generator](/packages/react/generators/storybook-configuration) (React and Next.js projects) +- [React Storybook Configuration Generator](/nx-api/react/generators/storybook-configuration) (React and Next.js projects) -- [Angular Storybook Configuration Generator](/packages/angular/generators/storybook-configuration) +- [Angular Storybook Configuration Generator](/nx-api/angular/generators/storybook-configuration) -- [React Native Storybook Configuration Generator](/packages/react-native/generators/storybook-configuration) +- [React Native Storybook Configuration Generator](/nx-api/react-native/generators/storybook-configuration) ## Examples diff --git a/packages/storybook/src/generators/configuration/configuration.ts b/packages/storybook/src/generators/configuration/configuration.ts index 8b0f5d10e8ebd..85d81e3d79c1e 100644 --- a/packages/storybook/src/generators/configuration/configuration.ts +++ b/packages/storybook/src/generators/configuration/configuration.ts @@ -35,12 +35,7 @@ import { pleaseUpgrade, storybookMajorVersion, } from '../../utils/utilities'; -import { - coreJsVersion, - nxVersion, - storybookVersion, - tsNodeVersion, -} from '../../utils/versions'; +import { coreJsVersion, nxVersion, tsNodeVersion } from '../../utils/versions'; import { interactionTestsDependencies } from './lib/interaction-testing.utils'; export async function configurationGenerator( diff --git a/packages/storybook/src/generators/configuration/lib/util-functions.ts b/packages/storybook/src/generators/configuration/lib/util-functions.ts index 0983b52851fc7..16e752f68b22a 100644 --- a/packages/storybook/src/generators/configuration/lib/util-functions.ts +++ b/packages/storybook/src/generators/configuration/lib/util-functions.ts @@ -549,13 +549,17 @@ export function createProjectStorybookDir( usesVite?: boolean, viteConfigFilePath?: string ) { - const projectDirectory = + let projectDirectory = projectType === 'application' ? isNextJs ? 'components' : 'src/app' : 'src/lib'; + if (uiFramework === '@storybook/vue3-vite') { + projectDirectory = 'src/components'; + } + const storybookConfigExists = projectIsRootProjectInStandaloneWorkspace ? tree.exists('.storybook/main.js') || tree.exists('.storybook/main.ts') : tree.exists(join(root, '.storybook/main.ts')) || diff --git a/packages/storybook/src/generators/init/init.ts b/packages/storybook/src/generators/init/init.ts index eafc8daa9af65..ef8c420fee3d9 100644 --- a/packages/storybook/src/generators/init/init.ts +++ b/packages/storybook/src/generators/init/init.ts @@ -83,6 +83,15 @@ function checkDependenciesInstalled(host: Tree, schema: Schema) { } } + if (schema.uiFramework === '@storybook/vue3-vite') { + if ( + !packageJson.dependencies['@storybook/vue3'] && + !packageJson.devDependencies['@storybook/vue3'] + ) { + devDependencies['@storybook/vue3'] = storybook7VersionToInstall; + } + } + if (schema.uiFramework === '@storybook/angular') { if ( !packageJson.dependencies['@angular/forms'] && diff --git a/packages/vue/.eslintrc.json b/packages/vue/.eslintrc.json index de85e34ac952c..d1d556aae4f5d 100644 --- a/packages/vue/.eslintrc.json +++ b/packages/vue/.eslintrc.json @@ -33,7 +33,8 @@ "nx", "typescript", "@nx/cypress", - "@nx/playwright" + "@nx/playwright", + "@nx/storybook" ] } ] diff --git a/packages/vue/docs/stories-examples.md b/packages/vue/docs/stories-examples.md new file mode 100644 index 0000000000000..e926f70673f32 --- /dev/null +++ b/packages/vue/docs/stories-examples.md @@ -0,0 +1,38 @@ +This generator will generate stories for all your components in your project. The stories will be generated using [Component Story Format 3 (CSF3)](https://storybook.js.org/blog/storybook-csf3-is-here/). + +```bash +nx g @nx/vue:stories project-name +``` + +You can read more about how this generator works, in the [Storybook for Vue overview page](/recipes/storybook/overview-vue#auto-generate-stories). + +When running this generator, you will be prompted to provide the following: + +- The `name` of the project you want to generate the configuration for. +- Whether you want to set up [Storybook interaction tests](https://storybook.js.org/docs/angular/writing-tests/interaction-testing) (`interactionTests`). If you choose `yes`, a `play` function will be added to your stories, and all the necessary dependencies will be installed. You can read more about this in the [Nx Storybook interaction tests documentation page](/packages/storybook/documents/interaction-tests).. + +You must provide a `name` for the generator to work. + +By default, this generator will also set up [Storybook interaction tests](https://storybook.js.org/docs/angular/writing-tests/interaction-testing). If you don't want to set up Storybook interaction tests, you can pass the `--interactionTests=false` option, but it's not recommended. + +There are a number of other options available. Let's take a look at some examples. + +## Examples + +### Ignore certain paths when generating stories + +```bash +nx g @nx/vue:stories --name=ui --ignorePaths=libs/ui/src/not-stories/**,**/**/src/**/*.other.* +``` + +This will generate stories for all the components in the `ui` project, except for the ones in the `libs/ui/src/not-stories` directory, and also for components that their file name is of the pattern `*.other.*`. + +This is useful if you have a project that contains components that are not meant to be used in isolation, but rather as part of a larger component. + +### Generate stories using JavaScript instead of TypeScript + +```bash +nx g @nx/vue:stories --name=ui --js=true +``` + +This will generate stories for all the components in the `ui` project using JavaScript instead of TypeScript. So, you will have `.stories.js` files next to your components. diff --git a/packages/vue/docs/storybook-configuration-examples.md b/packages/vue/docs/storybook-configuration-examples.md new file mode 100644 index 0000000000000..8bbfaac4176ba --- /dev/null +++ b/packages/vue/docs/storybook-configuration-examples.md @@ -0,0 +1,55 @@ +This generator will set up Storybook for your **Vue** project. You can also use this generator to generate Storybook configuration for your **Next.js** project. By default, starting Nx 16, Storybook v7 is used. + +```bash +nx g @nx/vue:storybook-configuration project-name +``` + +You can read more about how this generator works, in the [Storybook for Vue overview page](/recipes/storybook/overview-vue#generate-storybook-configuration-for-a-vue-project). + +When running this generator, you will be prompted to provide the following: + +- The `name` of the project you want to generate the configuration for. +- Whether you want to set up [Storybook interaction tests](https://storybook.js.org/docs/vue/writing-tests/interaction-testing) (`interactionTests`). If you choose `yes`, a `play` function will be added to your stories, and all the necessary dependencies will be installed. Also, a `test-storybook` target will be generated in your project's `project.json`, with a command to invoke the [Storybook `test-runner`](https://storybook.js.org/docs/vue/writing-tests/test-runner). You can read more about this in the [Nx Storybook interaction tests documentation page](/packages/storybook/documents/interaction-tests).. +- Whether you want to `generateStories` for the components in your project. If you choose `yes`, a `.stories.ts` file will be generated next to each of your components in your project. + +You must provide a `name` for the generator to work. + +By default, this generator will also set up [Storybook interaction tests](https://storybook.js.org/docs/vue/writing-tests/interaction-testing). If you don't want to set up Storybook interaction tests, you can pass the `--interactionTests=false` option, but it's not recommended. + +There are a number of other options available. Let's take a look at some examples. + +## Examples + +### Generate Storybook configuration + +```bash +nx g @nx/vue:storybook-configuration ui +``` + +This will generate Storybook configuration for the `ui` project using TypeScript for the Storybook configuration files (the files inside the `.storybook` directory, eg. `.storybook/main.ts`). + +### Ignore certain paths when generating stories + +```bash +nx g @nx/vue:storybook-configuration ui --generateStories=true --ignorePaths=libs/ui/src/not-stories/**,**/**/src/**/*.other.*,apps/my-app/**/*.something.ts +``` + +This will generate a Storybook configuration for the `ui` project and generate stories for all components in the `libs/ui/src/lib` directory, except for the ones in the `libs/ui/src/not-stories` directory, and the ones in the `apps/my-app` directory that end with `.something.ts`, and also for components that their file name is of the pattern `*.other.*`. + +This is useful if you have a project that contains components that are not meant to be used in isolation, but rather as part of a larger component. + +### Generate stories using JavaScript instead of TypeScript + +```bash +nx g @nx/vue:storybook-configuration ui --generateStories=true --js=true +``` + +This will generate stories for all the components in the `ui` project using JavaScript instead of TypeScript. So, you will have `.stories.js` files next to your components. + +### Generate Storybook configuration using JavaScript + +```bash +nx g @nx/vue:storybook-configuration ui --tsConfiguration=false +``` + +By default, our generator generates TypeScript Storybook configuration files. You can choose to use JavaScript for the Storybook configuration files of your project (the files inside the `.storybook` directory, eg. `.storybook/main.js`). diff --git a/packages/vue/generators.json b/packages/vue/generators.json index 2b52cb5da9f5e..b601513ac5036 100644 --- a/packages/vue/generators.json +++ b/packages/vue/generators.json @@ -33,6 +33,18 @@ "factory": "./src/generators/setup-tailwind/setup-tailwind", "schema": "./src/generators/setup-tailwind/schema.json", "description": "Set up Tailwind configuration for a project." + }, + "storybook-configuration": { + "factory": "./src/generators/storybook-configuration/configuration", + "schema": "./src/generators/storybook-configuration/schema.json", + "description": "Set up storybook for a Vue app or library.", + "hidden": false + }, + "stories": { + "factory": "./src/generators/stories/stories", + "schema": "./src/generators/stories/schema.json", + "description": "Create stories for all components declared in an app or library.", + "hidden": false } } } diff --git a/packages/vue/package.json b/packages/vue/package.json index ecf257c8c5b69..c76961e9f0f9e 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -28,6 +28,7 @@ "migrations": "./migrations.json" }, "dependencies": { + "minimatch": "3.0.5", "tslib": "^2.3.0", "@nx/devkit": "file:../devkit", "@nx/jest": "file:../jest", diff --git a/packages/vue/src/generators/component/component.spec.ts b/packages/vue/src/generators/component/component.spec.ts index d12c87a03411c..c2c6c1ffed198 100644 --- a/packages/vue/src/generators/component/component.spec.ts +++ b/packages/vue/src/generators/component/component.spec.ts @@ -138,27 +138,6 @@ describe('component', () => { }); }); - // TODO: figure out routing - xdescribe('--routing', () => { - it('should add routes to the component', async () => { - await componentGenerator(appTree, { - name: 'hello', - project: projectName, - routing: true, - }); - - const content = appTree - .read('my-lib/src/components/hello/hello.tsx') - .toString(); - expect(content).toContain('react-router-dom'); - expect(content).toMatch(/ { it('should create component under the directory', async () => { await componentGenerator(appTree, { diff --git a/packages/vue/src/generators/component/component.ts b/packages/vue/src/generators/component/component.ts index 18f8ab1084e79..41f0664ba2cce 100644 --- a/packages/vue/src/generators/component/component.ts +++ b/packages/vue/src/generators/component/component.ts @@ -114,7 +114,7 @@ async function normalizeOptions( const { sourceRoot: projectSourceRoot, projectType } = project; - const directory = await getDirectory(host, options); + const directory = await getDirectory(options); if (options.export && projectType === 'application') { logger.warn( @@ -134,7 +134,7 @@ async function normalizeOptions( }; } -async function getDirectory(host: Tree, options: Schema) { +async function getDirectory(options: Schema) { if (options.directory) return options.directory; if (options.flat) return 'components'; const { className, fileName } = names(options.name); diff --git a/packages/vue/src/generators/stories/__snapshots__/stories.app.spec.ts.snap b/packages/vue/src/generators/stories/__snapshots__/stories.app.spec.ts.snap new file mode 100644 index 0000000000000..198399979a1d2 --- /dev/null +++ b/packages/vue/src/generators/stories/__snapshots__/stories.app.spec.ts.snap @@ -0,0 +1,108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`vue:stories for applications should create the stories with interaction tests 1`] = ` +"import type { Meta, StoryObj } from '@storybook/vue3'; +import NxWelcome from './NxWelcome.vue'; + +import { within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; + +const meta: Meta = { + component: NxWelcome, + title: 'NxWelcome', +}; +export default meta; +type Story = StoryObj; + +export const Primary = { + args: {}, +}; + +export const Heading: Story = { + args: {}, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Welcome to NxWelcome!/gi)).toBeTruthy(); + }, +}; +" +`; + +exports[`vue:stories for applications should create the stories with interaction tests 2`] = ` +"import type { Meta, StoryObj } from '@storybook/vue3'; +import anotherCmp from './another-cmp.vue'; + +import { within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; + +const meta: Meta = { + component: anotherCmp, + title: 'anotherCmp', +}; +export default meta; +type Story = StoryObj; + +export const Primary = { + args: { + name: 'name', + displayAge: false, + age: 0, + }, +}; + +export const Heading: Story = { + args: { + name: 'name', + displayAge: false, + age: 0, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Welcome to anotherCmp!/gi)).toBeTruthy(); + }, +}; +" +`; + +exports[`vue:stories for applications should create the stories without interaction tests 1`] = ` +"import type { Meta, StoryObj } from '@storybook/vue3'; +import NxWelcome from './NxWelcome.vue'; + +const meta: Meta = { + component: NxWelcome, + title: 'NxWelcome', +}; +export default meta; +type Story = StoryObj; + +export const Primary = { + args: {}, +}; +" +`; + +exports[`vue:stories for applications should create the stories without interaction tests 2`] = ` +"import type { Meta, StoryObj } from '@storybook/vue3'; +import anotherCmp from './another-cmp.vue'; + +const meta: Meta = { + component: anotherCmp, + title: 'anotherCmp', +}; +export default meta; +type Story = StoryObj; + +export const Primary = { + args: { + name: 'name', + displayAge: false, + age: 0, + }, +}; +" +`; + +exports[`vue:stories for applications should not update existing stories 1`] = ` +"import { ComponentStory, ComponentMeta } from '@storybook/vue3'; +" +`; diff --git a/packages/vue/src/generators/stories/__snapshots__/stories.lib.spec.ts.snap b/packages/vue/src/generators/stories/__snapshots__/stories.lib.spec.ts.snap new file mode 100644 index 0000000000000..add2e9ebaecfc --- /dev/null +++ b/packages/vue/src/generators/stories/__snapshots__/stories.lib.spec.ts.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`vue:stories for libraries should create the stories with interaction tests 1`] = ` +"import type { Meta, StoryObj } from '@storybook/vue3'; +import testUiLib from './test-ui-lib.vue'; + +import { within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; + +const meta: Meta = { + component: testUiLib, + title: 'testUiLib', +}; +export default meta; +type Story = StoryObj; + +export const Primary = { + args: {}, +}; + +export const Heading: Story = { + args: {}, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Welcome to testUiLib!/gi)).toBeTruthy(); + }, +}; +" +`; + +exports[`vue:stories for libraries should create the stories with interaction tests 2`] = ` +"import type { Meta, StoryObj } from '@storybook/vue3'; +import anotherCmp from './another-cmp.vue'; + +import { within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; + +const meta: Meta = { + component: anotherCmp, + title: 'anotherCmp', +}; +export default meta; +type Story = StoryObj; + +export const Primary = { + args: { + name: 'name', + displayAge: false, + age: 0, + }, +}; + +export const Heading: Story = { + args: { + name: 'name', + displayAge: false, + age: 0, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Welcome to anotherCmp!/gi)).toBeTruthy(); + }, +}; +" +`; + +exports[`vue:stories for libraries should create the stories without interaction tests 1`] = ` +"import type { Meta, StoryObj } from '@storybook/vue3'; +import testUiLib from './test-ui-lib.vue'; + +const meta: Meta = { + component: testUiLib, + title: 'testUiLib', +}; +export default meta; +type Story = StoryObj; + +export const Primary = { + args: {}, +}; +" +`; + +exports[`vue:stories for libraries should create the stories without interaction tests 2`] = ` +"import type { Meta, StoryObj } from '@storybook/vue3'; +import anotherCmp from './another-cmp.vue'; + +const meta: Meta = { + component: anotherCmp, + title: 'anotherCmp', +}; +export default meta; +type Story = StoryObj; + +export const Primary = { + args: { + name: 'name', + displayAge: false, + age: 0, + }, +}; +" +`; diff --git a/packages/vue/src/generators/stories/lib/__snapshots__/component-story.spec.ts.snap b/packages/vue/src/generators/stories/lib/__snapshots__/component-story.spec.ts.snap new file mode 100644 index 0000000000000..c67675fdc57f5 --- /dev/null +++ b/packages/vue/src/generators/stories/lib/__snapshots__/component-story.spec.ts.snap @@ -0,0 +1,115 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`vue:component-story default setup component with other syntax of props defined should create a story with controls 1`] = ` +"import type { Meta, StoryObj } from '@storybook/vue3'; +import testUiLib from './test-ui-lib.vue'; + +import { within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; + + +const meta: Meta = { + component: testUiLib, + title: 'testUiLib', +}; +export default meta; +type Story = StoryObj; + + +export const Primary = { + args: { + name: 'name', + displayAge: false, + age: 0, + }, +}; + + +export const Heading: Story = { + args: { + name: 'name', + displayAge: false, + age: 0, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Welcome to testUiLib!/gi)).toBeTruthy(); + }, +}; + +" +`; + +exports[`vue:component-story default setup component with props defined should create a story with controls 1`] = ` +"import type { Meta, StoryObj } from '@storybook/vue3'; +import testUiLib from './test-ui-lib.vue'; + +import { within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; + + +const meta: Meta = { + component: testUiLib, + title: 'testUiLib', +}; +export default meta; +type Story = StoryObj; + + +export const Primary = { + args: { + name: 'name', + displayAge: false, + age: 0, + }, +}; + + +export const Heading: Story = { + args: { + name: 'name', + displayAge: false, + age: 0, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Welcome to testUiLib!/gi)).toBeTruthy(); + }, +}; + +" +`; + +exports[`vue:component-story default setup default component setup should properly set up the story 1`] = ` +"import type { Meta, StoryObj } from '@storybook/vue3'; +import testUiLib from './test-ui-lib.vue'; + +import { within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; + + +const meta: Meta = { + component: testUiLib, + title: 'testUiLib', +}; +export default meta; +type Story = StoryObj; + + +export const Primary = { + args: { + }, +}; + + +export const Heading: Story = { + args: { + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Welcome to testUiLib!/gi)).toBeTruthy(); + }, +}; + +" +`; diff --git a/packages/vue/src/generators/stories/lib/component-story.spec.ts b/packages/vue/src/generators/stories/lib/component-story.spec.ts new file mode 100644 index 0000000000000..1a49bffbc01ba --- /dev/null +++ b/packages/vue/src/generators/stories/lib/component-story.spec.ts @@ -0,0 +1,141 @@ +import { getProjects, Tree, updateProjectConfiguration } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import libraryGenerator from '../../library/library'; +import { createComponentStories } from './component-story'; +import { Linter } from '@nx/linter'; + +describe('vue:component-story', () => { + let appTree: Tree; + let cmpPath = 'test-ui-lib/src/components/test-ui-lib.vue'; + let storyFilePath = 'test-ui-lib/src/components/test-ui-lib.stories.ts'; + + describe('default setup', () => { + beforeEach(async () => { + appTree = await createTestUILib('test-ui-lib'); + }); + + describe('default component setup', () => { + beforeEach(async () => { + createComponentStories( + appTree, + { + interactionTests: true, + project: 'test-ui-lib', + }, + 'components/test-ui-lib.vue' + ); + }); + + it('should properly set up the story', () => { + expect(appTree.read(storyFilePath, 'utf-8')).toMatchSnapshot(); + }); + }); + + describe('component with props defined', () => { + beforeEach(async () => { + appTree.write( + cmpPath, + ` + + + + + ` + ); + + createComponentStories( + appTree, + { + interactionTests: true, + project: 'test-ui-lib', + }, + 'components/test-ui-lib.vue' + ); + }); + + it('should create a story with controls', () => { + expect(appTree.read(storyFilePath, 'utf-8')).toMatchSnapshot(); + }); + }); + + describe('component with other syntax of props defined', () => { + beforeEach(async () => { + appTree.write( + cmpPath, + ` + + + + + ` + ); + + createComponentStories( + appTree, + { + interactionTests: true, + project: 'test-ui-lib', + }, + 'components/test-ui-lib.vue' + ); + }); + + it('should create a story with controls', () => { + expect(appTree.read(storyFilePath, 'utf-8')).toMatchSnapshot(); + }); + }); + }); +}); + +export async function createTestUILib(libName: string): Promise { + let appTree = createTreeWithEmptyWorkspace(); + await libraryGenerator(appTree, { + name: libName, + linter: Linter.EsLint, + component: true, + skipFormat: true, + skipTsConfig: false, + unitTestRunner: 'jest', + projectNameAndRootFormat: 'as-provided', + }); + + const currentWorkspaceJson = getProjects(appTree); + + const projectConfig = currentWorkspaceJson.get(libName); + projectConfig.targets.lint.options.linter = 'eslint'; + + updateProjectConfiguration(appTree, libName, projectConfig); + + return appTree; +} diff --git a/packages/vue/src/generators/stories/lib/component-story.ts b/packages/vue/src/generators/stories/lib/component-story.ts new file mode 100644 index 0000000000000..de91aafdbbbf0 --- /dev/null +++ b/packages/vue/src/generators/stories/lib/component-story.ts @@ -0,0 +1,60 @@ +import { + generateFiles, + getProjects, + joinPathFragments, + normalizePath, + Tree, +} from '@nx/devkit'; +import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript'; +import { StorybookStoriesSchema } from '../stories'; +import { + camelCase, + createDefautPropsObject, + getDefinePropsObject, +} from './utils'; + +let tsModule: typeof import('typescript'); + +export function createComponentStories( + host: Tree, + { project, js, interactionTests }: StorybookStoriesSchema, + componentPath: string +) { + if (!tsModule) { + tsModule = ensureTypescript(); + } + const proj = getProjects(host).get(project); + const sourceRoot = proj.sourceRoot; + + const componentFilePath = joinPathFragments(sourceRoot, componentPath); + + const componentDirectory = componentFilePath.replace( + componentFilePath.slice(componentFilePath.lastIndexOf('/')), + '' + ); + + const componentFileName = componentFilePath + .slice(componentFilePath.lastIndexOf('/') + 1) + .replace('.vue', ''); + + const name = componentFileName; + const contents = host.read(componentFilePath, 'utf-8'); + const propsObject = getDefinePropsObject(contents); + + generateFiles( + host, + joinPathFragments(__dirname, `./files${js ? '/js' : '/ts'}`), + normalizePath(componentDirectory), + { + tmpl: '', + componentFileName: name, + componentImportFileName: `${name}.vue`, + props: createDefautPropsObject(propsObject), + componentName: camelCase(name), + interactionTests, + } + ); + if (contents === null) { + throw new Error(`Failed to read ${componentFilePath}`); + } +} diff --git a/packages/vue/src/generators/stories/lib/files/js/__componentFileName__.stories.js__tmpl__ b/packages/vue/src/generators/stories/lib/files/js/__componentFileName__.stories.js__tmpl__ new file mode 100644 index 0000000000000..5d89454f24309 --- /dev/null +++ b/packages/vue/src/generators/stories/lib/files/js/__componentFileName__.stories.js__tmpl__ @@ -0,0 +1,25 @@ +import componentName from './<%= componentImportFileName %>'; +<% if ( interactionTests ) { %> +import { within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; +<% } %> + +export default { + component: <%= componentName %>, + title: '<%= componentName %>' +}; + +export const Primary = { + args: {<% for (let prop of props) { %> + <%= prop.name %>: <%- prop.defaultValue %>,<% } %> + }, +}; + +<% if ( interactionTests ) { %> +export const Heading: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Welcome to <%=componentName%>!/gi)).toBeTruthy(); + }, +}; +<% } %> \ No newline at end of file diff --git a/packages/vue/src/generators/stories/lib/files/ts/__componentFileName__.stories.ts__tmpl__ b/packages/vue/src/generators/stories/lib/files/ts/__componentFileName__.stories.ts__tmpl__ new file mode 100644 index 0000000000000..5ac50f2208b8c --- /dev/null +++ b/packages/vue/src/generators/stories/lib/files/ts/__componentFileName__.stories.ts__tmpl__ @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/vue3'; +import <%= componentName %> from './<%= componentImportFileName %>'; +<% if ( interactionTests ) { %> +import { within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; +<% } %> + +const meta: Meta> = { + component: <%= componentName %>, + title: '<%= componentName %>', +}; +export default meta; +type Story = StoryObj; + + +export const Primary = { + args: {<% for (let prop of props) { %> + <%= prop.name %>: <%- prop.defaultValue %>,<% } %> + }, +}; + +<% if ( interactionTests ) { %> +export const Heading: Story = { + args: {<% for (let prop of props) { %> + <%= prop.name %>: <%- prop.defaultValue %>,<% } %> + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Welcome to <%=componentName%>!/gi)).toBeTruthy(); + }, +}; +<% } %> diff --git a/packages/vue/src/generators/stories/lib/utils.ts b/packages/vue/src/generators/stories/lib/utils.ts new file mode 100644 index 0000000000000..61a9dcd1be8a7 --- /dev/null +++ b/packages/vue/src/generators/stories/lib/utils.ts @@ -0,0 +1,76 @@ +export function camelCase(input: string): string { + if (input.indexOf('-') > 1) { + return input + .toLowerCase() + .replace(/-(.)/g, (_match, group1) => group1.toUpperCase()) + .replace('.', ''); + } else { + return input; + } +} + +export function createDefautPropsObject(propsObject: { + [key: string]: string; +}): { + name: string; + defaultValue: any; +}[] { + const props = []; + for (const key in propsObject) { + if (Object.prototype.hasOwnProperty.call(propsObject, key)) { + let defaultValueOfProp; + const element = propsObject[key]; + if (element === 'string') { + defaultValueOfProp = `'${key}'`; + } else if (element === 'boolean') { + defaultValueOfProp = false; + } else if (element === 'number') { + defaultValueOfProp = 0; + } + props.push({ + name: key, + defaultValue: defaultValueOfProp, + }); + } + } + return props; +} + +export function getDefinePropsObject(vueComponentFileContent: string): { + [key: string]: string; +} { + const scriptTagRegex = /]*>([\s\S]*?)<\/script>/; + const match = vueComponentFileContent?.match(scriptTagRegex); + let propsContent; + if (match && match[1]) { + const scriptContent = match[1].trim(); + const definePropsRegex = /defineProps<([\s\S]*?)>/; + const definePropsMatch = scriptContent.match(definePropsRegex); + + if (definePropsMatch && definePropsMatch[1]) { + propsContent = definePropsMatch[1].trim(); + } else { + const propsRegex = /(props:\s*\{[\s\S]*?\})/; + const match = scriptContent.match(propsRegex); + + if (match && match[1]) { + propsContent = match[1].trim(); + } else { + // No props found + } + } + } else { + // No props found + } + const attributes = {}; + + if (propsContent) { + const keyTypeRegex = /(\w+):\s*(\w+);/g; + let match; + + while ((match = keyTypeRegex.exec(propsContent)) !== null) { + attributes[match[1]] = match[2]; + } + } + return attributes; +} diff --git a/packages/vue/src/generators/stories/schema.json b/packages/vue/src/generators/stories/schema.json new file mode 100644 index 0000000000000..5e7a654cd0170 --- /dev/null +++ b/packages/vue/src/generators/stories/schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "$id": "NxVueStorybookStories", + "title": "Generate Vue Storybook stories", + "description": "Generate stories/specs for all components declared in a project.", + "type": "object", + "properties": { + "project": { + "type": "string", + "aliases": ["name", "projectName"], + "description": "Project for which to generate stories.", + "$default": { + "$source": "projectName", + "index": 0 + }, + "x-prompt": "For which project do you want to generate stories?", + "x-priority": "important" + }, + "generateCypressSpecs": { + "type": "boolean", + "description": "Automatically generate `*.spec.ts` files in the cypress e2e app generated by the cypress-configure generator." + }, + "cypressProject": { + "type": "string", + "description": "The Cypress project to generate the stories under. This is inferred from `project` by default." + }, + "interactionTests": { + "type": "boolean", + "description": "Set up Storybook interaction tests.", + "x-prompt": "Do you want to set up Storybook interaction tests?", + "x-priority": "important", + "default": true + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript files rather than TypeScript files.", + "default": false + }, + "ignorePaths": { + "type": "array", + "description": "Paths to ignore when looking for components.", + "items": { + "type": "string", + "description": "Path to ignore." + }, + "examples": [ + "apps/my-app/src/not-stories/**", + "**/**/src/**/not-stories/**", + "libs/my-lib/**/*.something.ts", + "**/**/src/**/*.other.*", + "libs/my-lib/src/not-stories/**,**/**/src/**/*.other.*,apps/my-app/**/*.something.ts" + ] + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false, + "x-priority": "internal" + } + }, + "required": ["project"], + "examplesFile": "../../../docs/stories-examples.md" +} diff --git a/packages/vue/src/generators/stories/stories.app.spec.ts b/packages/vue/src/generators/stories/stories.app.spec.ts new file mode 100644 index 0000000000000..5debc93bd1891 --- /dev/null +++ b/packages/vue/src/generators/stories/stories.app.spec.ts @@ -0,0 +1,272 @@ +import { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { Linter } from '@nx/linter'; +import applicationGenerator from '../application/application'; +import storiesGenerator from './stories'; + +const componentContent = ` + + + + +`; + +describe('vue:stories for applications', () => { + let appTree: Tree; + + beforeEach(async () => { + appTree = await createTestUIApp('test-ui-app'); + + // create another component + appTree.write( + 'test-ui-app/src/components/another-cmp/another-cmp.vue', + componentContent + ); + }); + + it('should create the stories with interaction tests', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-app', + }); + + expect( + appTree.read('test-ui-app/src/components/NxWelcome.stories.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + appTree.read( + 'test-ui-app/src/components/another-cmp/another-cmp.stories.ts', + 'utf-8' + ) + ).toMatchSnapshot(); + }); + + it('should create the stories without interaction tests', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-app', + interactionTests: false, + }); + + expect( + appTree.read('test-ui-app/src/components/NxWelcome.stories.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + appTree.read( + 'test-ui-app/src/components/another-cmp/another-cmp.stories.ts', + 'utf-8' + ) + ).toMatchSnapshot(); + }); + + it('should not update existing stories', async () => { + appTree.write( + 'test-ui-app/src/components/NxWelcome.stories.ts', + `import { ComponentStory, ComponentMeta } from '@storybook/vue3'` + ); + + await storiesGenerator(appTree, { + project: 'test-ui-app', + }); + + expect( + appTree.read('test-ui-app/src/components/NxWelcome.stories.ts', 'utf-8') + ).toMatchSnapshot(); + }); + + describe('ignore paths', () => { + beforeEach(() => { + appTree.write( + 'test-ui-app/src/components/test-path/ignore-it/another-one.vue', + componentContent + ); + + appTree.write( + 'test-ui-app/src/components/another-cmp/another-cmp-test.skip.vue', + componentContent + ); + }); + it('should generate stories for all if no ignorePaths', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-app', + }); + + expect( + appTree.exists('test-ui-app/src/components/NxWelcome.stories.ts') + ).toBeTruthy(); + expect( + appTree.exists( + 'test-ui-app/src/components/another-cmp/another-cmp.stories.ts' + ) + ).toBeTruthy(); + + expect( + appTree.exists( + 'test-ui-app/src/components/test-path/ignore-it/another-one.stories.ts' + ) + ).toBeTruthy(); + + expect( + appTree.exists( + 'test-ui-app/src/components/another-cmp/another-cmp-test.skip.stories.ts' + ) + ).toBeTruthy(); + }); + + it('should ignore entire paths', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-app', + ignorePaths: [ + `test-ui-app/src/components/another-cmp/**`, + `**/**/src/**/test-path/ignore-it/**`, + ], + }); + + expect( + appTree.exists('test-ui-app/src/components/NxWelcome.stories.ts') + ).toBeTruthy(); + expect( + appTree.exists( + 'test-ui-app/src/components/another-cmp/another-cmp.stories.ts' + ) + ).toBeFalsy(); + + expect( + appTree.exists( + 'test-ui-app/src/components/test-path/ignore-it/another-one.stories.ts' + ) + ).toBeFalsy(); + + expect( + appTree.exists( + 'test-ui-app/src/components/another-cmp/another-cmp-test.skip.stories.ts' + ) + ).toBeFalsy(); + }); + + it('should ignore path or a pattern', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-app', + ignorePaths: [ + 'test-ui-app/src/components/another-cmp/**/*.skip.*', + '**/**/src/**/test-path/**', + ], + }); + + expect( + appTree.exists('test-ui-app/src/components/NxWelcome.stories.ts') + ).toBeTruthy(); + expect( + appTree.exists( + 'test-ui-app/src/components/another-cmp/another-cmp.stories.ts' + ) + ).toBeTruthy(); + + expect( + appTree.exists( + 'test-ui-app/src/components/test-path/ignore-it/another-one.stories.ts' + ) + ).toBeFalsy(); + + expect( + appTree.exists( + 'test-ui-app/src/components/another-cmp/another-cmp-test.skip.stories.ts' + ) + ).toBeFalsy(); + }); + + it('should ignore direct path to component', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-app', + ignorePaths: ['test-ui-app/src/components/another-cmp/**/*.skip.vue'], + }); + + expect( + appTree.exists('test-ui-app/src/components/NxWelcome.stories.ts') + ).toBeTruthy(); + expect( + appTree.exists( + 'test-ui-app/src/components/another-cmp/another-cmp.stories.ts' + ) + ).toBeTruthy(); + + expect( + appTree.exists( + 'test-ui-app/src/components/test-path/ignore-it/another-one.stories.ts' + ) + ).toBeTruthy(); + + expect( + appTree.exists( + 'test-ui-app/src/components/another-cmp/another-cmp-test.skip.stories.ts' + ) + ).toBeFalsy(); + }); + + it('should ignore a path that has a nested component, but still generate nested component stories', async () => { + appTree.write( + 'test-ui-app/src/components/another-cmp/comp-a/comp-a.vue', + componentContent + ); + + await storiesGenerator(appTree, { + project: 'test-ui-app', + ignorePaths: [ + 'test-ui-app/src/components/another-cmp/another-cmp-test.skip.vue', + ], + }); + + expect( + appTree.exists('test-ui-app/src/components/NxWelcome.stories.ts') + ).toBeTruthy(); + expect( + appTree.exists( + 'test-ui-app/src/components/another-cmp/another-cmp.stories.ts' + ) + ).toBeTruthy(); + + expect( + appTree.exists( + 'test-ui-app/src/components/another-cmp/comp-a/comp-a.stories.ts' + ) + ).toBeTruthy(); + + expect( + appTree.exists( + 'test-ui-app/src/components/another-cmp/another-cmp-test.skip.stories.ts' + ) + ).toBeFalsy(); + }); + }); +}); + +export async function createTestUIApp( + libName: string, + plainJS = false +): Promise { + let appTree = createTreeWithEmptyWorkspace(); + + await applicationGenerator(appTree, { + e2eTestRunner: 'none', + linter: Linter.EsLint, + skipFormat: true, + style: 'css', + unitTestRunner: 'none', + name: libName, + js: plainJS, + projectNameAndRootFormat: 'as-provided', + }); + return appTree; +} diff --git a/packages/vue/src/generators/stories/stories.lib.spec.ts b/packages/vue/src/generators/stories/stories.lib.spec.ts new file mode 100644 index 0000000000000..5b58dd3d40a9a --- /dev/null +++ b/packages/vue/src/generators/stories/stories.lib.spec.ts @@ -0,0 +1,201 @@ +import { Tree } from '@nx/devkit'; +import storiesGenerator from './stories'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { Linter } from '@nx/linter'; +import libraryGenerator from '../library/library'; + +const componentContent = ` + + + + +`; + +describe('vue:stories for libraries', () => { + let appTree: Tree; + + beforeEach(async () => { + appTree = await createTestUILib('test-ui-lib'); + appTree.write( + 'test-ui-lib/src/components/another-cmp/another-cmp.vue', + componentContent + ); + }); + + it('should create the stories with interaction tests', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-lib', + }); + + expect( + appTree.read('test-ui-lib/src/components/test-ui-lib.stories.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + appTree.read( + 'test-ui-lib/src/components/another-cmp/another-cmp.stories.ts', + 'utf-8' + ) + ).toMatchSnapshot(); + + const packageJson = JSON.parse(appTree.read('package.json', 'utf-8')); + expect( + packageJson.devDependencies['@storybook/addon-interactions'] + ).toBeDefined(); + expect(packageJson.devDependencies['@storybook/test-runner']).toBeDefined(); + expect( + packageJson.devDependencies['@storybook/testing-library'] + ).toBeDefined(); + }); + + it('should create the stories without interaction tests', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-lib', + interactionTests: false, + }); + expect( + appTree.read('test-ui-lib/src/components/test-ui-lib.stories.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + appTree.read( + 'test-ui-lib/src/components/another-cmp/another-cmp.stories.ts', + 'utf-8' + ) + ).toMatchSnapshot(); + const packageJson = JSON.parse(appTree.read('package.json', 'utf-8')); + expect( + packageJson.devDependencies['@storybook/addon-interactions'] + ).toBeUndefined(); + expect( + packageJson.devDependencies['@storybook/test-runner'] + ).toBeUndefined(); + expect( + packageJson.devDependencies['@storybook/testing-library'] + ).toBeUndefined(); + }); + + describe('ignore paths', () => { + beforeEach(() => { + appTree.write( + 'test-ui-lib/src/components/test-path/ignore-it/another-one.vue', + componentContent + ); + + appTree.write( + 'test-ui-lib/src/components/another-cmp/another-cmp.skip.vue', + componentContent + ); + }); + it('should generate stories for all if no ignorePaths', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-lib', + }); + + expect( + appTree.exists( + 'test-ui-lib/src/components/another-cmp/another-cmp.stories.ts' + ) + ).toBeTruthy(); + + expect( + appTree.exists( + 'test-ui-lib/src/components/test-path/ignore-it/another-one.stories.ts' + ) + ).toBeTruthy(); + + expect( + appTree.exists( + 'test-ui-lib/src/components/another-cmp/another-cmp.skip.stories.ts' + ) + ).toBeTruthy(); + }); + + it('should ignore entire paths', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-lib', + ignorePaths: [ + 'test-ui-lib/src/components/another-cmp/**', + '**/**/src/**/test-path/ignore-it/**', + ], + }); + + expect( + appTree.exists( + 'test-ui-lib/src/components/another-cmp/another-cmp.stories.ts' + ) + ).toBeFalsy(); + + expect( + appTree.exists( + 'test-ui-lib/src/components/test-path/ignore-it/another-one.stories.ts' + ) + ).toBeFalsy(); + + expect( + appTree.exists( + 'test-ui-lib/src/components/another-cmp/another-cmp.skip.stories.ts' + ) + ).toBeFalsy(); + }); + + it('should ignore path or a pattern', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-lib', + ignorePaths: [ + 'test-ui-lib/src/components/another-cmp/**/*.skip.*', + '**/test-ui-lib/src/**/test-path/**', + ], + }); + + expect( + appTree.exists( + 'test-ui-lib/src/components/another-cmp/another-cmp.stories.ts' + ) + ).toBeTruthy(); + + expect( + appTree.exists( + 'test-ui-lib/src/components/test-path/ignore-it/another-one.stories.ts' + ) + ).toBeFalsy(); + + expect( + appTree.exists( + 'test-ui-lib/src/components/another-cmp/another-cmp.skip.stories.ts' + ) + ).toBeFalsy(); + }); + }); +}); + +export async function createTestUILib( + libName: string, + plainJS = false +): Promise { + let appTree = createTreeWithEmptyWorkspace(); + + await libraryGenerator(appTree, { + linter: Linter.EsLint, + component: true, + skipFormat: true, + skipTsConfig: false, + unitTestRunner: 'none', + name: libName, + projectNameAndRootFormat: 'as-provided', + }); + + return appTree; +} diff --git a/packages/vue/src/generators/stories/stories.ts b/packages/vue/src/generators/stories/stories.ts new file mode 100644 index 0000000000000..d33a04787ad98 --- /dev/null +++ b/packages/vue/src/generators/stories/stories.ts @@ -0,0 +1,106 @@ +import { + addDependenciesToPackageJson, + convertNxGenerator, + ensurePackage, + formatFiles, + GeneratorCallback, + getProjects, + joinPathFragments, + ProjectConfiguration, + runTasksInSerial, + Tree, + visitNotIgnoredFiles, +} from '@nx/devkit'; +import { basename, join } from 'path'; +import minimatch = require('minimatch'); +import { nxVersion } from '../../utils/versions'; +import { createComponentStories } from './lib/component-story'; + +export interface StorybookStoriesSchema { + project: string; + interactionTests?: boolean; + js?: boolean; + ignorePaths?: string[]; + skipFormat?: boolean; + cypressProject?: string; + generateCypressSpecs?: boolean; +} + +export async function createAllStories( + tree: Tree, + projectName: string, + interactionTests: boolean, + js: boolean, + projectConfiguration: ProjectConfiguration, + ignorePaths?: string[] +) { + const { sourceRoot, root } = projectConfiguration; + let componentPaths: string[] = []; + const projectPath = joinPathFragments(sourceRoot, 'components'); + visitNotIgnoredFiles(tree, projectPath, (path) => { + // Ignore private files starting with "_". + if (basename(path).startsWith('_')) return; + if (ignorePaths?.some((pattern) => minimatch(path, pattern))) return; + if (path.endsWith('.vue')) { + // Let's see if the .stories.* file exists + const ext = path.slice(path.lastIndexOf('.')); + const storyPathJs = `${path.split(ext)[0]}.stories.js`; + const storyPathTs = `${path.split(ext)[0]}.stories.ts`; + + if (!tree.exists(storyPathJs) && !tree.exists(storyPathTs)) { + componentPaths.push(path); + } + } + }); + + await Promise.all( + componentPaths.map(async (componentPath) => { + const relativeCmpDir = componentPath.replace(join(sourceRoot, '/'), ''); + createComponentStories( + tree, + { + project: projectName, + interactionTests, + js, + }, + relativeCmpDir + ); + }) + ); +} + +export async function storiesGenerator( + host: Tree, + schema: StorybookStoriesSchema +) { + const projects = getProjects(host); + const projectConfiguration = projects.get(schema.project); + schema.interactionTests = schema.interactionTests ?? true; + await createAllStories( + host, + schema.project, + schema.interactionTests, + schema.js, + projectConfiguration, + schema.ignorePaths + ); + + const tasks: GeneratorCallback[] = []; + + if (schema.interactionTests) { + const { interactionTestsDependencies, addInteractionsInAddons } = + ensurePackage('@nx/storybook', nxVersion); + tasks.push( + addDependenciesToPackageJson(host, {}, interactionTestsDependencies()) + ); + addInteractionsInAddons(host, projectConfiguration); + } + + if (!schema.skipFormat) { + await formatFiles(host); + } + return runTasksInSerial(...tasks); +} + +export default storiesGenerator; +export const storiesSchematic = convertNxGenerator(storiesGenerator); diff --git a/packages/vue/src/generators/storybook-configuration/__snapshots__/configuration.spec.ts.snap b/packages/vue/src/generators/storybook-configuration/__snapshots__/configuration.spec.ts.snap new file mode 100644 index 0000000000000..4af5261ed2233 --- /dev/null +++ b/packages/vue/src/generators/storybook-configuration/__snapshots__/configuration.spec.ts.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`vue:storybook-configuration should configure everything and install correct dependencies 1`] = ` +"import type { StorybookConfig } from '@storybook/vue3-vite'; + +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { mergeConfig } from 'vite'; + +const config: StorybookConfig = { + stories: ['../src/components/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, + + viteFinal: async (config) => + mergeConfig(config, { + plugins: [nxViteTsPaths()], + }), +}; + +export default config; + +// To customize your Vite configuration you can use the viteFinal field. +// Check https://storybook.js.org/docs/react/builders/vite#configuration +// and https://nx.dev/recipes/storybook/custom-builder-configs +" +`; + +exports[`vue:storybook-configuration should generate stories for components 1`] = `null`; + +exports[`vue:storybook-configuration should generate stories for components without interaction tests 1`] = `null`; diff --git a/packages/vue/src/generators/storybook-configuration/configuration.spec.ts b/packages/vue/src/generators/storybook-configuration/configuration.spec.ts new file mode 100644 index 0000000000000..666e087816d33 --- /dev/null +++ b/packages/vue/src/generators/storybook-configuration/configuration.spec.ts @@ -0,0 +1,165 @@ +import { logger, Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { Linter } from '@nx/linter'; +import applicationGenerator from '../application/application'; +import componentGenerator from '../component/component'; +import libraryGenerator from '../library/library'; +import storybookConfigurationGenerator from './configuration'; + +// nested code imports graph from the repo, which might have innacurate graph version +jest.mock('nx/src/project-graph/project-graph', () => ({ + ...jest.requireActual('nx/src/project-graph/project-graph'), + createProjectGraphAsync: jest + .fn() + .mockImplementation(async () => ({ nodes: {}, dependencies: {} })), +})); +const componentContent = ` + + + + +`; + +describe('vue:storybook-configuration', () => { + let appTree; + beforeEach(async () => { + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + jest.spyOn(logger, 'debug').mockImplementation(() => {}); + jest.resetModules(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should configure everything and install correct dependencies', async () => { + appTree = await createTestUILib('test-ui-lib'); + await storybookConfigurationGenerator(appTree, { + name: 'test-ui-lib', + }); + + expect( + appTree.read('test-ui-lib/.storybook/main.ts', 'utf-8') + ).toMatchSnapshot(); + expect(appTree.exists('test-ui-lib/tsconfig.storybook.json')).toBeTruthy(); + + const packageJson = JSON.parse(appTree.read('package.json', 'utf-8')); + expect(packageJson.devDependencies['@storybook/vue3-vite']).toBeDefined(); + expect(packageJson.devDependencies['@storybook/vue3']).toBeDefined(); + expect( + packageJson.devDependencies['@storybook/addon-interactions'] + ).toBeDefined(); + expect(packageJson.devDependencies['@storybook/test-runner']).toBeDefined(); + expect( + packageJson.devDependencies['@storybook/testing-library'] + ).toBeDefined(); + }); + + it('should generate stories for components', async () => { + appTree = await createTestUILib('test-ui-lib'); + await storybookConfigurationGenerator(appTree, { + name: 'test-ui-lib', + generateStories: true, + }); + + expect( + appTree.exists('test-ui-lib/src/components/test-ui-lib.stories.ts') + ).toBeTruthy(); + }); + + it('should configure everything at once', async () => { + appTree = await createTestAppLib('test-ui-app'); + await storybookConfigurationGenerator(appTree, { + name: 'test-ui-app', + }); + + expect(appTree.exists('test-ui-app/.storybook/main.ts')).toBeTruthy(); + expect(appTree.exists('test-ui-app/tsconfig.storybook.json')).toBeTruthy(); + }); + + it('should generate stories for components', async () => { + appTree = await createTestAppLib('test-ui-app'); + await storybookConfigurationGenerator(appTree, { + name: 'test-ui-app', + generateStories: true, + }); + + expect( + appTree.read( + 'test-ui-app/src/components/my-component/my-component.stories.ts', + 'utf-8' + ) + ).toMatchSnapshot(); + }); + + it('should generate stories for components without interaction tests', async () => { + appTree = await createTestAppLib('test-ui-app'); + await storybookConfigurationGenerator(appTree, { + name: 'test-ui-app', + generateStories: true, + interactionTests: false, + }); + + expect( + appTree.read( + 'test-ui-app/src/components/my-component/my-component.stories.ts', + 'utf-8' + ) + ).toMatchSnapshot(); + }); +}); + +export async function createTestUILib(libName: string): Promise { + let appTree = createTreeWithEmptyWorkspace(); + + await libraryGenerator(appTree, { + linter: Linter.EsLint, + component: true, + skipFormat: true, + skipTsConfig: false, + unitTestRunner: 'none', + name: libName, + projectNameAndRootFormat: 'as-provided', + }); + return appTree; +} + +export async function createTestAppLib( + libName: string, + plainJS = false +): Promise { + let appTree = createTreeWithEmptyWorkspace(); + + await applicationGenerator(appTree, { + e2eTestRunner: 'none', + linter: Linter.EsLint, + skipFormat: false, + style: 'css', + unitTestRunner: 'none', + name: libName, + js: plainJS, + projectNameAndRootFormat: 'as-provided', + }); + + await componentGenerator(appTree, { + name: 'my-component', + project: libName, + directory: 'app', + }); + + return appTree; +} diff --git a/packages/vue/src/generators/storybook-configuration/configuration.ts b/packages/vue/src/generators/storybook-configuration/configuration.ts new file mode 100644 index 0000000000000..6a1d806ed5ee9 --- /dev/null +++ b/packages/vue/src/generators/storybook-configuration/configuration.ts @@ -0,0 +1,52 @@ +import { StorybookConfigureSchema } from './schema'; +import storiesGenerator from '../stories/stories'; +import { + convertNxGenerator, + ensurePackage, + formatFiles, + Tree, +} from '@nx/devkit'; +import { nxVersion } from '../../utils/versions'; + +async function generateStories(host: Tree, schema: StorybookConfigureSchema) { + await storiesGenerator(host, { + project: schema.name, + js: schema.js, + ignorePaths: schema.ignorePaths, + skipFormat: true, + interactionTests: schema.interactionTests ?? true, + }); +} + +export async function storybookConfigurationGenerator( + host: Tree, + schema: StorybookConfigureSchema +) { + const { configurationGenerator } = ensurePackage< + typeof import('@nx/storybook') + >('@nx/storybook', nxVersion); + + const installTask = await configurationGenerator(host, { + name: schema.name, + js: schema.js, + linter: schema.linter, + tsConfiguration: schema.tsConfiguration ?? true, // default is true + interactionTests: schema.interactionTests ?? true, // default is true + configureStaticServe: schema.configureStaticServe, + uiFramework: '@storybook/vue3-vite', + skipFormat: true, + }); + + if (schema.generateStories) { + await generateStories(host, schema); + } + + await formatFiles(host); + + return installTask; +} + +export default storybookConfigurationGenerator; +export const storybookConfigurationSchematic = convertNxGenerator( + storybookConfigurationGenerator +); diff --git a/packages/vue/src/generators/storybook-configuration/schema.d.ts b/packages/vue/src/generators/storybook-configuration/schema.d.ts new file mode 100644 index 0000000000000..f884a3d9603f4 --- /dev/null +++ b/packages/vue/src/generators/storybook-configuration/schema.d.ts @@ -0,0 +1,12 @@ +import { Linter } from '@nx/linter'; + +export interface StorybookConfigureSchema { + name: string; + interactionTests?: boolean; + generateStories?: boolean; + js?: boolean; + tsConfiguration?: boolean; + linter?: Linter; + ignorePaths?: string[]; + configureStaticServe?: boolean; +} diff --git a/packages/vue/src/generators/storybook-configuration/schema.json b/packages/vue/src/generators/storybook-configuration/schema.json new file mode 100644 index 0000000000000..b585929ef95e5 --- /dev/null +++ b/packages/vue/src/generators/storybook-configuration/schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "$id": "NxVueStorybookConfigure", + "title": "Vue Storybook Configure", + "description": "Set up Storybook for a Vue project.", + "type": "object", + "properties": { + "name": { + "type": "string", + "aliases": ["project", "projectName"], + "description": "Project for which to generate Storybook configuration.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "For which project do you want to generate Storybook configuration?", + "x-dropdown": "projects", + "x-priority": "important" + }, + "interactionTests": { + "type": "boolean", + "description": "Set up Storybook interaction tests.", + "x-prompt": "Do you want to set up Storybook interaction tests?", + "x-priority": "important", + "alias": ["configureTestRunner"], + "default": true + }, + "generateStories": { + "type": "boolean", + "description": "Automatically generate `*.stories.ts` files for components declared in this project?", + "x-prompt": "Automatically generate *.stories.ts files for components declared in this project?", + "default": true, + "x-priority": "important" + }, + "configureStaticServe": { + "type": "boolean", + "description": "Specifies whether to configure a static file server target for serving storybook. Helpful for speeding up CI build/test times.", + "x-prompt": "Configure a static file server for the storybook instance?", + "default": true, + "x-priority": "important" + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript story files rather than TypeScript story files.", + "default": false + }, + "tsConfiguration": { + "type": "boolean", + "description": "Configure your project with TypeScript. Generate main.ts and preview.ts files, instead of main.js and preview.js.", + "default": true + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["eslint"], + "default": "eslint" + }, + "ignorePaths": { + "type": "array", + "description": "Paths to ignore when looking for components.", + "items": { + "type": "string", + "description": "Path to ignore." + }, + "examples": [ + "apps/my-app/src/not-stories/**", + "**/**/src/**/not-stories/**", + "libs/my-lib/**/*.something.ts", + "**/**/src/**/*.other.*", + "libs/my-lib/src/not-stories/**,**/**/src/**/*.other.*,apps/my-app/**/*.something.ts" + ] + } + }, + "required": ["name"], + "examplesFile": "../../../docs/storybook-configuration-examples.md" +}