diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index f636171f52f8ed..8438bc235b72f1 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -1922,6 +1922,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", @@ -3079,6 +3087,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", @@ -3170,6 +3186,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 dd801c1d65749c..c8f6b0476ea34b 100644 --- a/docs/generated/manifests/nx.json +++ b/docs/generated/manifests/nx.json @@ -2396,6 +2396,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", @@ -3839,6 +3849,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", @@ -3954,6 +3974,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 f1c09d2cf87229..b047d20dd1b7a4 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 e88acfeb037694..6c03d8049fafe7 100644 --- a/docs/generated/packages/storybook/documents/overview.md +++ b/docs/generated/packages/storybook/documents/overview.md @@ -53,7 +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](/packages/angular/generators/storybook-configuration), [React](/packages/react/generators/storybook-configuration), [React Native](/packages/react-native/generators/storybook-configuration)), in the field `uiFramework` you must choose one of the following Storybook frameworks: +If you are NOT using a framework-specific generator (for [Angular](/packages/angular/generators/storybook-configuration), [React](/packages/react/generators/storybook-configuration), [React Native](/packages/react-native/generators/storybook-configuration), [Vue](/packages/vue/generators/storybook-configuration)), in the field `uiFramework` you must choose one of the following Storybook frameworks: - `@storybook/angular` - `@storybook/html-webpack5` @@ -98,6 +98,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,16 +117,6 @@ 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 - -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: - -```shell -nx g @nx/storybook:configuration project-name --tsConfiguration=true -``` - -[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. - ### Running Storybook Serve Storybook using this command: @@ -157,12 +154,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 +167,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 +177,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 +196,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/map.json b/docs/map.json index ac1c941031e00e..7aa1d082b651bc 100644 --- a/docs/map.json +++ b/docs/map.json @@ -737,6 +737,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 e88acfeb037694..6c03d8049fafe7 100644 --- a/docs/shared/packages/storybook/plugin-overview.md +++ b/docs/shared/packages/storybook/plugin-overview.md @@ -53,7 +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](/packages/angular/generators/storybook-configuration), [React](/packages/react/generators/storybook-configuration), [React Native](/packages/react-native/generators/storybook-configuration)), in the field `uiFramework` you must choose one of the following Storybook frameworks: +If you are NOT using a framework-specific generator (for [Angular](/packages/angular/generators/storybook-configuration), [React](/packages/react/generators/storybook-configuration), [React Native](/packages/react-native/generators/storybook-configuration), [Vue](/packages/vue/generators/storybook-configuration)), in the field `uiFramework` you must choose one of the following Storybook frameworks: - `@storybook/angular` - `@storybook/html-webpack5` @@ -98,6 +98,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,16 +117,6 @@ 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 - -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: - -```shell -nx g @nx/storybook:configuration project-name --tsConfiguration=true -``` - -[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. - ### Running Storybook Serve Storybook using this command: @@ -157,12 +154,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 +167,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 +177,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 +196,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 00000000000000..17534bcdd46417 --- /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](/packages/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](/packages/vue/generators/storybook-configuration), like this: + +```shell +nx g @nx/vue:storybook-configuration project-name +``` + +## Auto-generate Stories + +The [`@nx/vue:storybook-configuration` generator](/packages/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](/packages/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](/packages#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 c61af4b215ad55..9d581f98985801 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -127,6 +127,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 00000000000000..96836618a13eb0 --- /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/src/generators/configuration/configuration.ts b/packages/storybook/src/generators/configuration/configuration.ts index 8b0f5d10e8ebd8..85d81e3d79c1ef 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 0983b52851fc7f..576121aac1fdd3 100644 --- a/packages/storybook/src/generators/configuration/lib/util-functions.ts +++ b/packages/storybook/src/generators/configuration/lib/util-functions.ts @@ -30,6 +30,7 @@ import { UiFramework7 } from '../../../utils/models'; import { nxVersion } from '../../../utils/versions'; import { findEslintFile } from '@nx/linter/src/generators/utils/eslint-file'; import { useFlatConfig } from '@nx/linter/src/utils/flat-config'; +import { PathLike, statSync } from 'fs'; const DEFAULT_PORT = 4400; @@ -549,13 +550,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')) || @@ -788,3 +793,11 @@ export function renameAndMoveOldTsConfig( ); } } + +function directoryExists(path: PathLike): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} diff --git a/packages/storybook/src/generators/init/init.ts b/packages/storybook/src/generators/init/init.ts index eafc8daa9af65d..ef8c420fee3d95 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/docs/stories-examples.md b/packages/vue/docs/stories-examples.md new file mode 100644 index 00000000000000..e926f70673f328 --- /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 00000000000000..8bbfaac4176baf --- /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 1f247e517b147f..b69f9ace63847b 100644 --- a/packages/vue/generators.json +++ b/packages/vue/generators.json @@ -28,6 +28,18 @@ "aliases": ["c"], "x-type": "component", "description": "Create a Vue component." + }, + "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/src/generators/component/component.spec.ts b/packages/vue/src/generators/component/component.spec.ts index d12c87a03411c7..c2c6c1ffed198f 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 18f8ab1084e793..41f0664ba2cced 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 00000000000000..9915aa171e9c2d --- /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); + 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); + 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 00000000000000..1fdcbc8be0e76f --- /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); + 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); + 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 00000000000000..372f0ae95708d7 --- /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); + 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); + 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); + 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 00000000000000..1a49bffbc01baf --- /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 00000000000000..de91aafdbbbf0e --- /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 00000000000000..241cc89b1d8176 --- /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); + 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 00000000000000..c248bcc432b840 --- /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); + 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 00000000000000..61a9dcd1be8a78 --- /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 00000000000000..5e7a654cd0170d --- /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 00000000000000..5debc93bd18911 --- /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 00000000000000..5b58dd3d40a9a7 --- /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 00000000000000..d33a04787ad98a --- /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 00000000000000..4af5261ed22335 --- /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 00000000000000..666e087816d331 --- /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 00000000000000..6a1d806ed5ee96 --- /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 00000000000000..f884a3d9603f49 --- /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 00000000000000..b585929ef95e59 --- /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" +}