From 2c477b8392c76a058b9edd635f65018376714735 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 2 Mar 2024 16:32:33 -0700 Subject: [PATCH] feat: Add Vue/Quasar component library tutorials --- ...-vue-component-library-storybook-vitest.md | 789 ++++++++++++++++++ ...3-01-quasar-component-library-storybook.md | 353 ++++++++ 2 files changed, 1142 insertions(+) create mode 100644 _posts/2024-02-29-vue-component-library-storybook-vitest.md create mode 100644 _posts/2024-03-01-quasar-component-library-storybook.md diff --git a/_posts/2024-02-29-vue-component-library-storybook-vitest.md b/_posts/2024-02-29-vue-component-library-storybook-vitest.md new file mode 100644 index 0000000..1cf5430 --- /dev/null +++ b/_posts/2024-02-29-vue-component-library-storybook-vitest.md @@ -0,0 +1,789 @@ +--- +layout: post +title: How to create a custom Vue Component Library with robust testing via Storybook and Vitest +tags: + - Software Engineering + - Test Driven Development + - Continuous Integration + - Vue + - Vitest + - Storybook + - GitHub Actions +--- + +Many developers are familiar with using a component library (e.g. Vuetify, Quasar, PrimeVue, etc.), but there comes a time when it's useful to create your own. +- Multiple projects which should share custom components. +- A large project which is hard to test. + +This comprehensive, step-by-step guide will take you from a new, empty folder all the way to a fully-functional component library with robust automated testing tools which follow software engineering best practices. The techniques described here are neither theoretical nor a mere "proof-of-concept" -- they have been used successfully in critical production applications. + +Let's get started! + +*Steps 1, 3, and 4 of this guide are heavily inspired by [Andreas Riedmüller](https://dev.to/receter)'s excellent [tutorial for creating a React Component Library](https://dev.to/receter/how-to-create-a-react-component-library-using-vites-library-mode-4lma)* +## 1. Scaffold an empty Vite project + +Vite is a fabulous build tool that works especially well with Vue projects like a Vue Component Library. + +Run `npm create vite@latest` to scaffold a new project. You'll be asked for a project name, then choose the Vue framework and the TypeScript variant to complete the setup. + +```bash +$ npm create vite@latest + +Need to install the following packages: +create-vite@5.2.1 +Ok to proceed? (y) y +✔ Project name: … my-component-library +✔ Select a framework: › Vue +✔ Select a variant: › TypeScript + +Scaffolding project in /my-component-library... + +Done. Now run: + + cd my-component-library + npm install + npm run dev + +``` + +Run the recommended commands to verify that installs and works correctly. +```shell +$ npm install + +added 46 packages, and audited 47 packages in 9s + +5 packages are looking for funding + run `npm fund` for details + +found 0 vulnerabilities +``` + +```bash +$ npm run dev + + VITE v5.1.4 ready in 329 ms + + ➜ Local: http://localhost:5173/ + ➜ Network: use --host to expose + ➜ press h + enter to show help +``` + +Open your browser to the `Local` URL shown in your console to see the your new webpage powered by Vite! + +To wrap up scaffolding, let's do a few tiny polishing steps... + +### Install TypeScript types for NodeJS APIs +Currently, newly scaffolded Vite projects do not include the TypeScript type declarations for built-in NodeJS APIs. We'll need these later in the guide. + +```bash +npm install --save-dev @types/node +``` + +### Create an `.nvmrc` file (optional) +Many developers (and CI tools) use [`nvm` (Node Version Manager)](https://github.com/nvm-sh/nvm) to easily configure the correct NodeJS version for each project. Creating an `.nvmrc` file clearly communicates your project's preferred version of `node`. + +```bash +node --version > .nvmrc +``` + +### Make a `git` commit (optional) +At this point, I like to make a commit to snapshot my progress + +```bash +git add . +git commit --message "chore: Scaffold new Vite project" +``` + +Committing immediately after scaffolding gives two major benefits: first, I can easily revert back to the scaffolded version if I mess up a later step; and second, a `git blame` can distinguish default configurations from custom configurations. + +## 2. Create some sample library code + +Let's add some placeholder code to our library! The goal at this point is have just enough library code to meaningfully evaluate the build tooling we will add in later steps. As such, I strongly recommend starting with only trivial library code so you can focus any debugging efforts on the build tooling. + +For this tutorial, we'll use a simple `Greeting` component which calls a utility function to generate its greeting. + +Create a custom utility function +```typescript +export function createGreeting(name?: string): string { + return name === undefined ? "Hello!" : `Hello ${name}!` +} +``` +{: file='lib/utils/createGreeting.ts' } + +And create a custom component +{% raw %} +```vue + + + +``` +{: file='lib/components/Greeting.vue' } + +{% endraw %} + +> For now, ignore the TypeScript error on the import line. We'll fix that in a later step. +{: .prompt-info } + +Finally, export your component from your library +```typescript +export * as Greeting from "./components/Greeting.vue" +``` +{: file='lib/main.ts' } + +At this point, if you run `npm run build` you'll notice that the generated `dist` directory contains none of the library code you just wrote. We'll fix the build in the next step. + + + +## 3. Convert Vite to library mode + +A newly scaffolded Vite project always starts in *project* mode (i.e. building a complete web application), but, with a little more configuration, Vite also supports building JavaScript libraries. + +
+ How does Vite's project mode work? + +Understanding the "magic" behind Vite's project mode isn't required for building a component library. But that knowledge does make it easier to understand the changes made to support library mode in Vite. + +When you run `npm run dev`, here's what happens behind the scenes: + 1. `npm` parses your `package.json`, looks in the `scripts` section to find the configured `dev` command. + 2. `npm` runs the `dev` command (in this case `vite`) in the context of your currently installed `node_modules`. + 3. Vite loads its config file `vite.config.ts` and [starts an http server rooted at `index.html`](https://vitejs.dev/guide/#index-html-and-project-root), the default entrypoint for the new Vite project. + - Notice the `vue` plugin which enables Vite to transpile `.vue` files to plain JS. + +Opening a web browser loads the `index.html` like a normal webpage, opening your web application. + - The `index.html` defines an empty `
` (where the web application will mount). + - The `` loads the `src/main.ts` script (dynamically transpiled to JS by Vite) which creates the Vue app and mounts it at the previously defined `div`. + +
+ +You'll notice that if you run `npm run build`, Vite will create a `dist` directory containing [`index.html` (the default entrypoint)](https://vitejs.dev/guide/#index-html-and-project-root) and the optimized code from your `src` directory. The key difference for *library* mode is telling Vite to build a different entrypoint to your code. + +### Create a library entrypoint +In Step 2, we created a separate `lib` folder for our library code. We left the `index.html` file and `src` directory alone since they are a helpful playground to use while building components. + +Now we need to add the library entrypoint to `vite.config.ts` + +```diff +import { defineConfig } from 'vite' ++ import { resolve } from 'path' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], ++ build: { ++ copyPublicDir: false, ++ lib: { ++ entry: resolve(__dirname, 'lib/main.ts'), ++ formats: ['es'] ++ } ++ } +}) +``` +{: file='vite.config.ts' } + +> Setting `build.copyPublicDir` to `false` [excludes files in the `public` folder from the build](https://vitejs.dev/config/build-options.html#build-copypublicdir). For libraries, that's typically the preferred option since [`public` is intended to hold static assets for a webpage](https://vitejs.dev/guide/assets.html#the-public-directory). +{: .prompt-info } + +> The [default value for `build.lib.formats` is `['es', 'umd']`](https://vitejs.dev/config/build-options.html#build-lib) . The `'umd'` format is mostly used for [standalone JS files distributed via a CDN](https://stackoverflow.com/a/77284527/14765128), so it isn't necessary for a component library. Omitting `'umd'` also has the benefit of allowing us to [omit the `build.lib.name` key](https://vitejs.dev/config/build-options.html#build-lib). +{: .prompt-info } + +Now, running `npm run build` will generate a bundle in the `dist` folder containing your `lib`rary code instead of the `src` code! + +### TypeScript Support in the IDE + +Right now, TypeScript doesn't work in our `lib`rary code because the default configuration only looks at the `src` code. Let's fix that! + +Copy Vite's types to your library +```bash +cp src/vite-env.d.ts lib/vite-env.d.ts +``` + +Enable TypeScript in your IDE for the `lib` directory +```diff +- "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], ++ "include": ["src", "lib"], +``` +{: file='tsconfig.json' } + +TypeScript should now be working in your IDE! + +### Add TypeScript declarations to the built library + +We also want to include TypeScript type declarations in the built library so that library users also benefit from the IDE autocompletion and quality checks. However, we want to omit type declarations for `src` files (our dev playground) since the `src` code won't be there either. + +Start by creating a separate TypeScript config for building the library +```json +{ + "extends": "./tsconfig.json", + "include": ["lib"] +} +``` +{: file='tsconfig-build.json' } + +And update your `package.json`'s `build` script to use the new TypeScript config. Now your build will only run type checks for code in your `lib` directory. +```diff + "scripts": { + … +- "build": "vue-tsc && vite build", ++ "build": "vue-tsc --p ./tsconfig-build.json && vite build", +``` +{: file='package.json' } + +To distribute TypeScript type declarations with your library, install the Vite plugin called [`vite-plugin-dts`](https://github.com/qmhc/vite-plugin-dts) and point it to the `tsconfig-build.json` you just created. + +```bash +npm add --save-dev vite-plugin-dts +``` + +```diff ++import dts from 'vite-plugin-dts' +… + plugins: [ + vue(), ++ dts({ tsconfigPath: "tsconfig-build.json" }), + ], +… +``` +{: file='vite.config.ts' } + +Now, `npm run build` should include several `.d.ts` files in your `dist` folder containing the TypeScript declarations for your library code! + +## 4. Optimize the build + +At this point, the built library bundle is nearly **50kb**, a huge amount of code for such a simple component library. The reason for the excessive size is that our build is packaging in a copy of Vue. Since our component library will be used within projects which already have Vue installed, this additional bundling is entirely unnecessary. + +### Remove Vue from the bundle + +Let's update our Vite config to exclude Vue from the built library bundle +```diff + build: { + … ++ rollupOptions: { ++ external: ['vue'], ++ } + } +``` +{: file='vite.config.ts' } + +We'll also want to mark Vue as a [peer dependency](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#peerdependencies) so that our library users are reminded that they need to install Vue themselves. + +```diff +-   "dependencies": { ++   "peerDependencies": { +    "vue": "^3.4.19" +  }, +``` +{: file='package.json' } + +You'll also want to add Vue as a development dependency so it continues to be installed while you develop the library. + +``` +npm install --save-dev vue +``` + +Now `npm run build` produces a much more reasonably sized bundle of only **~0.5kb**, a 100x size reduction! + + + + + +## 5. Add testing tools to verify software quality + +### Add Storybook.js for Component Driven Development + +As we add more components to our library, it is helpful to have a dedicated tool to preview and test our components. The most powerful of these tools is [Storybook.js](https://storybook.js.org/). + +#### Install Storybook +You can install Storybook using their [installation guide for Vue projects](https://storybook.js.org/docs/get-started/install), but I'll repeat the key steps here for completeness. + +> NOTE: At the time of writing, there is a [shortcoming with the current Storybook install script](https://github.com/storybookjs/storybook/issues/22431#issuecomment-1630086092). You may have to force `npm` to resolve the package `jackspeak` (one of Storybook's transitive dependencies) to a specific version. +> +> ```diff +> "dependencies": { … d} +>+   "resolutions": { +>+ "jackspeak": "2.1.1" +>+ } +> ``` +{: file='package.json' } +{: .prompt-info } + +Install Storybook.js +``` +npx storybook@latest init +``` + +Storybook should automatically configure itself and launch in your browser at https://localhost:6006. Explore the tool to get a feel for what it does and how it works. + +#### Add a Story for a library component + +Stories are written in files named `*.stories.ts` and, by convention, are placed next to the component they test. + +Create a Story file for the `Greeting.vue` component we created at the beginning of the tutorial. +```typescript +import type { Meta, StoryObj } from '@storybook/vue3'; + +import Greeting from './Greeting.vue'; + +// CONFIGURATION +const meta = { + title: 'components/Greeting', + component: Greeting, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// STORIES +export const Default: Story = { + args: {}, +}; + +export const HelloWorld: Story = { + args: { + name: 'World', + }, +}; +``` +{: file='lib/components/Greeting.stories.ts' } + +To show this story within Storybook in your browser, you'll have to tell Storybook to look for stories in the `lib` folder. For this tutorial, we won't be writing stories for anything in the `src` folder so we'll have Storybook ignore `src` entirely. + +```diff +const config: StorybookConfig = { +- stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], ++ stories: ["../lib/**/*.mdx", "../lib/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + … +``` +{: file='.storybook/main.ts' } + +Restart Storybook for this config change to take effect. + +``` +npm run storybook +``` + +Your `Greeting` story should now available in your Storybook! + +#### Add testing addons to Storybook + +Storybook supports a rich ecosystem of addons for testing your components. + +##### Install the Accessibility Addon + +I like starting with the [Accessibility addon](https://storybook.js.org/addons/@storybook/addon-a11y) since it provides a solid foundation for later testing plugins (particularly interaction testing). + +```bash +npm install --save-dev @storybook/addon-a11y +``` + +```diff +const config: StorybookConfig = { + … + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-interactions", ++ "@storybook/addon-a11y", + ], +``` +{: file='.storybook/main.ts' } + +Now your stories will have an **Accessibility** tab which automatically checks your component for Accessibility issues and recommends fixes. +##### Install the Storybook Test Runner + +The [Storybook Test Runner](https://storybook.js.org/docs/writing-tests/test-runner) turns all of your stories into automated unit, smoke, and interaction tests, no extra effort required! + +Install the test runner +```bash +npm install --save-dev @storybook/test-runner @storybook/testing-library +``` + +Install [`playwright`](https://playwright.dev/) (used by the test runner for smoke and interaction tests) +``` +npx playwright install +``` + +Start Storybook in one terminal +```bash +npm run storybook +``` + +And run the tests against that Storybook instance in another terminal +```bash +npm run test-storybook +``` + +##### Add Storybook Test Coverage + +Measuring your test coverage -- which lines of production code get executed while running the test suite -- can help you find gaps in your testing. + +To have Storybook collect test coverage during its test runs, install the [Storybook Test Coverage Addon](https://storybook.js.org/docs/writing-tests/test-coverage) +```bash +npm install --save-dev @storybook/addon-coverage +``` + +You'll also need to include the coverage addon in your Storybook configuration +```diff +const config: StorybookConfig = { + … + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-interactions", + "@storybook/addon-a11y", ++ "@storybook/addon-coverage", + ], +``` +{: file='.storybook/main.ts' } + +To measure test coverage in your `.vue` files, you'll also need to a `.nycrc.json` config file instructing the test runner to instrument `.vue` files. +``` +touch .nycrc.json +``` + +```json +{ "extension": [".vue",".js","jsx",".ts","tsx"] } +``` +{: file='.nycrc.json' } + +Restart your Storybook instance if necessary, then rerun the test suite, this time with the `--coverage` flag. +```bash +npm run test-storybook -- --coverage +``` + +The coverage metrics will be saved to `coverage/storybook/coverage-storybook.json`. You can generate a coverage report using [`nyc`](https://github.com/istanbuljs/nyc). +```bash +$ npx nyc report --reporter=text --temp-dir coverage/storybook +--------------------|---------|----------|---------|---------|------------------- +File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s +--------------------|---------|----------|---------|---------|------------------- +All files | 100 | 100 | 100 | 100 | + components | 100 | 100 | 100 | 100 | + Greeting.vue | 100 | 100 | 100 | 100 | + utils | 100 | 100 | 100 | 100 | + createGreeting.ts | 100 | 100 | 100 | 100 | +--------------------|---------|----------|---------|---------|------------------- + +$ npx nyc report --reporter=text-summary --temp-dir coverage/storybook +=============================== Coverage summary =============================== +Statements : 100% ( 4/4 ) +Branches : 100% ( 2/2 ) +Functions : 100% ( 1/1 ) +Lines : 100% ( 4/4 ) +================================================================================ +``` + +### Add Vitest for unit testing for utility functions + +Your library may include utility functions in addition to UI components. To test these utility functions directly, you can add a unit testing framework like [Vitest](https://vitest.dev/). + +#### Install `vitest` + +```bash +npm install --save-dev vitest +``` + +Add a `test` script to your `package.json` +```diff +scripts: { +… ++ "test:unit": "vitest" +} +``` +{: file='package.json' } + +Add a test file for the `createGreeting.ts` utility function we created at the start of the tutorial +```typescript +import { describe, expect, it } from "vitest" +import { createGreeting } from "../createGreeting" + +describe("createGreeting", () => { + it("creates a generic greeting when no name is provided", () => { + expect(createGreeting()).toBe("Hello!") + }) + it("creates a specific greeting when a name is provided", () => { + expect(createGreeting("World")).toBe("Hello World!") + }) +}) +``` +{: file='lib/utils/__tests__/createGreeting.test.ts' } + +Now you can run the unit tests for your utility code +```bash +npm run test:unit +``` + +#### Setup Test Coverage Measurement for Vitest + +Create a `vitest.config.ts` with the following contents +```typescript +import { defineConfig } from 'vitest/config' +import vue from "@vitejs/plugin-vue" +import tsconfigPaths from "vite-tsconfig-paths" + +export default defineConfig({ + test: { + coverage: { + all: true, // Include files with 0% coverage in the report. + provider: 'istanbul', // or 'v8' + include: ['lib/**/*'], + // Omit test code from the report (both Storybook and Vitest) + exclude: ['lib/**/*.stories.{ts,tsx}', 'lib/**/*.test.{ts,tsx}'], + reporter: ["text", "text-summary", "json", "json-summary"], + reportsDirectory: "coverage/unit", + }, + }, + plugins: [ + vue(), + tsconfigPaths(), + ], +}) +``` +{: file='vitest.config.ts' } + +Install [`vite-tsconfig-paths`](https://www.npmjs.com/package/vite-tsconfig-paths) so Vitest can better support TypeScript resolutions. +```bash +npm install --save-dev vite-tsconfig-paths +``` + +Run the unit tests with the `--coverage` flag to see the coverage results. If you are prompted to install `@vitest/coverage-istanbul`, accept it, then re-run the test command. +```bash +npm run test:unit -- --coverage +``` + +### Combine Test Coverage Results from Storybook and Vitest + +Since we're running two different testing tools, we get two separate, and thus incomplete, test coverage reports. We want to merge those reports into one complete test coverage report. + +Currently, the data backing the Storybook test coverage report is saved in the `coverage/storybook/` folder while Vitest's test coverage data is in `coverage/unit/` + +The underlying CLI tool for test coverage, [`nyc`](https://github.com/istanbuljs/nyc), has a built-in `merge` command which will merge all test coverage reports found in a specified directory. As a result, we can use the following script to generate our combined report + +```bash +mkdir --parents coverage/merged +cp coverage/storybook/coverage-storybook.json coverage/merged/coverage-storybook.json +cp coverage/unit/coverage-final.json coverage/merged/coverage-unit.json +npx nyc merge coverage/merged coverage/coverage-final.json +``` + +Now you can view a complete, combined test coverage report: + +```bash +$ npx nyc report --reporter=text --temp-dir coverage/ +--------------------|---------|----------|---------|---------|------------------- +File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s +--------------------|---------|----------|---------|---------|------------------- +All files | 100 | 100 | 100 | 100 | + components | 100 | 100 | 100 | 100 | + Greeting.vue | 100 | 100 | 100 | 100 | + utils | 100 | 100 | 100 | 100 | + createGreeting.ts | 100 | 100 | 100 | 100 | +--------------------|---------|----------|---------|---------|------------------- + +$ npx nyc report --reporter=text-summary --temp-dir coverage/ + +=============================== Coverage summary =============================== +Statements : 100% ( 4/4 ) +Branches : 100% ( 2/2 ) +Functions : 100% ( 1/1 ) +Lines : 100% ( 4/4 ) +================================================================================ +``` + +### Running Tests in a Continuous Integration System + +Running your tests regularly in an automated environment helps you find and fix errors in your code faster. + +To run our tests in a continuous integration system (e.g. GitHub Actions), we need to write scripts that: + - Run the tests headlessly (i.e. with zero user interaction). + - Specify all dependencies required for the production code and its tests. + +#### Run headless tests locally +Let's start by updating the scripts in `package.json` to clearly specify whether they run interactively (for local development) or headlessly (for use in `ci`). + +```diff +"scripts": { ++ "cdd": "storybook dev --port 6006", ++ "test:unit": "vitest", ++ "test:unit:ci": "vitest --coverage --run", ++ "test:cdd": "test-storybook --watch --browsers chromium,firefox,webkit", ++ "test:cdd:ci": "test-storybook --coverage --browsers chromium,firefox,webkit", ++ "build": "vue-tsc -p ./tsconfig-build.json && vite build --watch", ++ "build:ci": "vue-tsc -p ./tsconfig-build.json && vite build", ++ "build:cdd": "storybook build", +} +``` +{: file='package.json' } + +> I use the name `cdd` (shorthand for Component Driven Development) to refer to my Storybook scripts because I prefer to focus on engineering principles over the tool that enables them. +{: .prompt-info } + +From there, we can write the scripts to use in CI. I like to use a `Makefile` for this purpose + +```bash +.PHONY: build +build: + npm run build + +.PHONY: build-ci +build-ci: + npm run build:ci + + +.PHONY: test +test: + npx --yes concurrently --kill-others --success first --names "CDD,UNIT" -c "magenta,blue" \ + "npx --yes npm run test:cdd" \ + "npx --yes npm run test:unit" + +.PHONY: test-ci +test-ci: + rm -rf coverage +# Run the unit tests + npm run test:unit:ci +# Run the Storybook tests +# - Credits: https://storybook.js.org/docs/writing-tests/test-runner#run-against-non-deployed-storybooks + npx --yes concurrently --kill-others --success first --names "SB,CDD" -c "magenta,blue" \ + "npx --yes npm run cdd -- --port 6006 --ci --quiet" \ + "npx --yes wait-on tcp:6006 && npm run test:cdd:ci" +# Merge the coverage reports + mkdir --parents coverage/merged + cp coverage/storybook/coverage-storybook.json coverage/merged/coverage-storybook.json + cp coverage/unit/coverage-final.json coverage/merged/coverage-unit.json + npx nyc merge coverage/merged coverage/coverage-final.json +# Generate the coverage report + for reporter in text text-summary lcov json-summary ; do \ + npx nyc report --reporter=$$reporter -t coverage/ --report-dir coverage/ ; \ + done +``` +{: file='Makefile' } + +With this `Makefile` you now have four useful commands for local development and CI verification +```bash +$ make build # Run the production build in *watch* mode (i.e. rebuild on file saves) +$ make build-ci # One-off, headless production build +$ make test # Run both unit/cdd tests in *watch* mode (i.e. rerun on file save) +$ make test-ci # One-off, headless test run +``` + +The magic part of these scripts is the use of the [`concurrently` package](https://www.npmjs.com/package/concurrently) to boot a Storybook server headlessly for the Storybook testing library to run against (thanks [Storybook docs](https://storybook.js.org/docs/writing-tests/test-runner#run-against-non-deployed-storybooks)!). +#### Run tests in GitHub Actions + +If you host your component library repository in GitHub, you can create a GitHub Actions workflow file which will run the tests for you on every commit you push up. + +```yaml +name: "CI(branch): Test, Release" + +on: + push: + branches: [master, qa, dev, "[0-9]+.[0-9]+.[0-9]+", "[0-9]+.[0-9]+.[0-9]+-[a-zA-Z]+"] + tags: + - "[0-9]+.[0-9]+.[0-9]+" + pull_request: + branches: [master, qa, dev, "[0-9]+.[0-9]+.[0-9]+"] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Install Playwright + run: npx playwright install --with-deps + + - name: Build the package + run: make build-ci + + - name: Run the Unit Tests + run: make test-ci + + - name: Publish internal package releases + # Run only on `push` events to tags/branches other than `master` + if: github.ref_name != 'master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + run: | + echo "NO-OP: Replace with your code for publishing an internal version" + + - name: Push the production package releases + # Only run this job for the `master` branch + if: github.ref_name == 'master' + run: | + echo "NO-OP: Replace with your code for publishing a production version" +``` +{: file='.github/workflows/ci-test-release.yml' } + +## 6. Other Notes + +### Hot-reloading the Vue component library within a larger project + +My original motivation for creating a component library was to improve the testability of a large Vue application, not to share components between multiple applications. In that situation, my team wanted to preserve the ability to edit the component library and see it hot-reload within the large Vue application. + +Attempting to hot-reload code changes to an npm dependency is extraordinarily difficult. +- The dependency code is usually an optimized, compiled form of the original source code, so a hot-reload requires rebuilding the original source code. [Vite's `build --watch` mode](https://vitejs.dev/guide/build#rebuild-on-files-changes) impressively managed to effectively tree-shake the build process so that it only rebuilt the smallest number of JS chunks possible; however, even that optimized build still took 3-4 seconds for tiny code changes (e.g. changing a static text label). +- npm dependencies are loaded from `node_modules`, which is only updated by `npm install`. Any hot-reload process would also need to automatically re-install the component library. + +For these reasons, we decided to import our component library code directly (i.e. *not* as an npm dependency) and let our larger Vue project's compiler handle the entire build process for both the library code and the main application code. + +Include your component library within the larger project as a submodule +```bash +git submodule init +git submodule add COMPONENT_LIBRARY_URL lib/COMPONENT_LIBRARY +``` + +Within the larger project, configure resolution aliases for your component library's code. + +Add a TypeScript alias +```diff +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { ++ "my-component-lib/*": ["lib/my-component-lib/lib/*"] + } + } + +} +``` +{: file='tsconfig.json' } + +Add a Vite alias +```diff +import { defineConfig } from "vite"; ++ import path from "path" + +export default defineConfig({ + resolve: { + alias: { ++ "my-component-lib": path.join(__dirname, "./lib/my-component-lib/lib"), + } + } +}) +``` +{: file='vite.config.ts' } + +Now your larger Vue project can directly import code from your component library and have the library code hot-reload/build just like the code of the larger application. +```vue +import Greeting from "my-component-lib/components/Greeting.vue" +``` +{: file='your-vue-app/SomePage.vue' } + +## Conclusion + +Testing UI code is can be really hard, especially when attempting to improve the testability of a large, established project. Creating a custom component library can (1) provide a clean environment for developing and testing individual components and (2) open the door to sharing code with between projects for further productivity gains. + +I hope this tutorial has helped you create a robustly testable component library which can provide a solid foundation for your development work for years to come. + +Please share your experience, insights, and/or critiques in the comments below! diff --git a/_posts/2024-03-01-quasar-component-library-storybook.md b/_posts/2024-03-01-quasar-component-library-storybook.md new file mode 100644 index 0000000..7c0996f --- /dev/null +++ b/_posts/2024-03-01-quasar-component-library-storybook.md @@ -0,0 +1,353 @@ +--- +layout: post +title: How to use Quasar in a custom Vue Component Library with Storybook +tags: + - Software Engineering + - Test Driven Development + - Quasar + - Vue + - Storybook +--- + +[Quasar](https://quasar.dev/) is a popular Vue framework with a large component library and best-in-class support for building apps for web/mobile/browser extension/electron/etc. from the same codebase. Yet, despite significant community interest, it has been difficult to find solid, versatile answers for how to create custom component libraries based on Quasar. This tutorial provides those answers, demonstrating how to create a standalone Quasar component library which can be used in any Vue application, whether or not that app runs via the Quasar CLI, the Vue CLI, or another tool. + + + +
+ History of community interest in custom Quasar component libraries (2019-2023) + +23 August 2019 - [Quasar Issue #4956](https://github.com/quasarframework/quasar/issues/4956) +> *OP:* Is there any way to build a quasar project as a standalone component (SFC (?)) to be used in another project, using the quasar cli preferably, or if it is not possible with it, any other way? +> +> *Maintainer:* The purpose of a project folder is not to build a component, but a website/app. + +10 September 2019 - [Quasar Issue #5063](https://github.com/quasarframework/quasar/issues/5063) +> *OP:* I built the following scenario without full success: +> - Project A: A vue-cli + quasar (as plugin) +> - Project B: A plain Vue-cli project that includes Project A as JS dependency +> +> *Maintainer*: Quasar CLI's purpose (and even Vue CLI's purpose) is not to build a component library, but to build a website/app. + + 1 March 2020 - [Quasar Issue #5063](https://github.com/quasarframework/quasar/issues/5063#issuecomment-593114114) +> Here is a basic use case : we have multiple Vue apps, and are creating a centralized component library imported by all the apps. These components have dependencies on multiple Quasar components. . After adding Quasar as a plugin, ... and importing it in other apps, it results in conflicts + + 12 March 2020 - [Quasar Issue #6604](https://github.com/quasarframework/quasar/issues/6604) +> *OP:* When having more than one Vue application, a good practice is to put common components in an external component library. But it seems that putting Quasar components in an external library and using it in a Quasar app is not possible currently (or at least is not documented) +> +> *Quasar Collaborator:* Your components are either `.js` files or `.vue` files. You can copy them in any new project. I think you mean something and I don't get it. Can you explain why the copy/paste of the files with components is not good? +> +> *OP:* Just to be sure : you are suggesting me that, every time we make an update on a component, we should copy / paste it manually on our 15 others apps ? +> +> We are a team of 8, sometimes working on the same components at the same time, it will result in versioning nightmare. Refactoring components in a single, versionable, external lib is a key feature of Vue +> We were thinking about migrating, but we cannot use Quasar with such a constraint + +20 February 2023 - [StackOverflow](https://stackoverflow.com/q/75510418/14765128) +> I am creating a library that contains some company standard components. It is built upon Quasar components. When I try to use the library it gives the following error: [Vue warn]: Failed to resolve component: q-input. + +
+ +## Why build a custom Quasar component library? + +For sharing components between multiple Quasar projects, the official recommendation is to create a [Quasar App Extension](https://quasar.dev/app-extensions/introduction/). App Extensions *require* the Quasar CLI, which beneficially removes tons of pain from creating a web app/mobile app/browser extension/electron app/etc., but unfortunately brings multiple downsides: + + - *Framework Lock-In*: Using the Quasar CLI for your project [tightly couples your code to the Quasar framework](https://blog.cleancoder.com/uncle-bob/2014/05/11/FrameworkBound.html). + - *Reduced Testability:* Quasar supports [Cypress as an App Extension](https://testing.quasar.dev/packages/e2e-cypress/), but other common testing tools still have significant limitations: [Jest has an unsupported beta package](https://testing.quasar.dev/packages/unit-jest/) , [Vitest support is still in Alpha](https://testing.quasar.dev/packages/unit-vitest/), and the main testing page is full of [deprecation notices with no replacements](https://testing.quasar.dev/). Community efforts to support popular sandboxed component development tools like [Storybook](https://github.com/quasarframework/quasar/issues/11654#issuecomment-1618295838) or [Histoire](https://github.com/histoire-dev/histoire/issues/105) have been blocked by the difficulty of replicating Quasar CLI's [significant boot complexity](https://quasar.dev/quasar-cli-webpack/boot-files/#quasar-app-flow) within the sandboxed environment. + - *Reduced Ecosystem Interoperability:* The Quasar CLI requirement prevents Quasar App Extensions from being used in vanilla Vue projects, further limiting their reach. + - *Delayed Access to Ecosystem Improvements*: When using the Quasar CLI, access to many ecosystem-wide innovations must wait until the Quasar CLI adds official support. Sometimes that support takes years to arrive. For example, it took until February 2024 for Vite >1.0 support to [reach a usable beta stage](https://github.com/quasarframework/quasar/releases/tag/%40quasar%2Fapp-vite-v2.0.0-beta.1), nearly [two full years after the community raised the need](https://github.com/quasarframework/quasar/issues/14077#issuecomment-1353213893) and began developing solutions. + +For projects which do not have a strong need to share a nearly identical experience across Web, Mobile, Browser Extension, and/or Electron (probably rare given the dramatically different use cases for each environment), these downsides of the Quasar CLI are significant, even prohibitive. + +This tutorial for creating a custom, standalone Quasar component library mitigates those downsides by answering the following questions: + - How can one use Quasar components (including custom derivatives) in a vanilla Vue project? + - How can one improve the testability of a project managed by the Quasar CLI? + +## How to create a custom Quasar component library + +### Create a vanilla Vue Component Library. + +A custom Quasar component library is just a vanilla Vue Component Library with Quasar added on top. + +This tutorial assumes you already have an existing Vue Component Library. If not, I strongly recommend following my tutorial [How to create a custom Vue Component Library with robust testing via Storybook and Vitest]({% link _posts/2024-02-29-vue-component-library-storybook-vitest.md %}). You can also reference the excellent tutorials by [FreeCodeCamp](https://www.freecodecamp.org/news/how-to-create-and-publish-a-vue-component-library-update/) and [LogRocket](https://blog.logrocket.com/building-vue-3-component-library/). + +### Install Quasar as a Vue CLI plugin + +> Quasar's official docs for their [Vue CLI Quasar Plugin](https://quasar.dev/start/vue-cli-plugin/) start with a scary-looking warning banner. The concerns expressed in that warning refer to building Quasar **applications** with the Vue CLI, and thus do not apply to our **component library**. +{: .prompt-info } + +Using the Vue CLI to manage your component library avoids all the limitations of the Quasar CLI. Additionally, if you use your Vue CLI-managed component library within a larger Quasar CLI managed application, you can still retain the cross-platform benefits of the Quasar CLI. + +While Quasar's docs include instructions for configuring the [Vue CLI Quasar Plugin](https://quasar.dev/start/vue-cli-plugin/), the installer assumes you are modifying a full Vue application, so it takes many steps which are unnecessary for a component library. As such, this section will detail a manual installation (which is ultimately simpler). If you prefer to install Quasar via the Vue CLI (e.g. to compare the official installation with these manual instructions) feel free to expand the following details note: + +
+ How to install Quasar via the Vue CLI (reference-only) + +Verify that you have Vue CLI v4.5.11+ installed +```bash +$ vue --version +@vue/cli 5.0.8 +``` + +If you get a lower version or an error like `command 'vue' not found` then globally install the latest version of the Vue CLI. +```bash +npm uninstall --global vue-cli // Vue CLI 2.x.x only + +npm install --global @vue/cli +``` + +Now you can add install Quasar into your component library with the command `vue add quasar`. When the setup wizard asks **Allow Quasar to replace App.vue, ...** answer **No** since those changes don't apply to a component library. Otherwise, answer the questions according to your preference. +```bash +$ vue add quasar + +📦 Installing vue-cli-plugin-quasar... + +added 6 packages, and audited 1379 packages in 4s + +218 packages are looking for funding + run `npm fund` for details + +found 0 vulnerabilities +✔ Successfully installed plugin: vue-cli-plugin-quasar + +? Allow Quasar to replace App.vue, About.vue, Home.vue and (if available) router.js? No +? Pick your favorite CSS preprocessor: Sass +? Choose Quasar Icon Set Material +? Default Quasar language pack - one from +https://github.com/quasarframework/quasar/tree/dev/ui/lang en-US +? Use RTL support? No +? Select features: Roboto font, Material, Material Outlined, Material Round, Material +Sharp, Fontawesome, Ionicons, MDI, Eva + +🚀 Invoking generator for vue-cli-plugin-quasar... +📦 Installing additional dependencies... + +added 5 packages, and audited 1384 packages in 6s + +221 packages are looking for funding + run `npm fund` for details + +found 0 vulnerabilities +⚓ Running completion hooks... + +✔ Successfully invoked generator for plugin: vue-cli-plugin-quasar +``` + +Note that this installation adds several files only applicable to web applications (icons to the public folder, overwriting favicons, adding Quasar to the runtime `dependencies`, `vue.config.ts`, etc.) In my experience, the installed Sass styles are also frequently broken. + +
+ +#### Manual installation of Quasar as a Vue CLI plugin + +Add Quasar and its Vue CLI plugin as a dev dependency +```bash +npm install --save-dev quasar @quasar/extras vue-cli-plugin-quasar sass sass-loader +``` + +Add Quasar to your library's `peerDependencies` +```diff +{ + "peerDependencies": { + "vue": "^3.4.19", ++ "@quasar/extras": "^1.0.0", // NOTE: Match the versions in your devDependencies ++ "quasar": "^2.0.0" + }, +} +``` +{: file='package.json' } + +Create a stylesheet for your custom Quasar variables (e.g. color themes) +```sass +// It's highly recommended to change the default colors +// to match your app's branding. + +$primary : #027BE3 +$secondary : #26A69A +$accent : #9C27B0 + +$dark : #1D1D1D + +$positive : #21BA45 +$negative : #C10015 +$info : #31CCEC +$warning : #F2C037 +``` +{: file='lib/styles/quasar.variables.sass' } + +Create a stylesheet importing Quasar's global styles +```sass +// The built-in Quasar styles. Defined separately to avoid conflicts +// with custom Quasar styles defined in downstream projects. +@import 'quasar/src/css/index.sass' +@import 'quasar/src/css/variables.sass' +``` +{: file='lib/styles/quasar.global.sass' } + +Create the `quasar-user-options.js` for the Quasar Vue CLI plugin's configuration settings +```javascript +import './styles/quasar.variables.sass' + +// NOTE: Omit any fonts/icon packs you do not use. +import '@quasar/extras/roboto-font/roboto-font.css' +import '@quasar/extras/material-icons/material-icons.css' +import '@quasar/extras/material-icons-outlined/material-icons-outlined.css' +import '@quasar/extras/material-icons-round/material-icons-round.css' +import '@quasar/extras/material-icons-sharp/material-icons-sharp.css' +import '@quasar/extras/fontawesome-v5/fontawesome-v5.css' +import '@quasar/extras/ionicons-v4/ionicons-v4.css' +import '@quasar/extras/mdi-v4/mdi-v4.css' +import '@quasar/extras/eva-icons/eva-icons.css' + +// To be used on app.use(Quasar, { ... }) +export default { + config: {}, + plugins: { + } +} +``` +{: file='lib/styles/quasar-user-options.js' } + +Finally create a `setupQuasarApp.ts` script with a function to augment a given Vue `app` with Quasar functionality. +```typescript +import { type App } from 'vue' +import { Quasar } from 'quasar' +// @ts-expect-error - quasar-user-options is not yet typed +import quasarUserOptions from './quasar-user-options' + +export function setupQuasarApp(app: App) { + app.use(Quasar, quasarUserOptions) +} +``` +{: file='lib/setupQuasarApp.ts' } + +That's it for the library installation! + +If your downstream project uses the Quasar CLI then you may be able to import and use components directly from your component library. Otherwise, (e.g. if your downstream project is vanilla Vue) you will have to import the Quasar stylesheets and the apply the Quasar setup function yourself. Your code might look something like this: + +```diff +import { createApp } from 'vue' +import App from './App.vue' + ++ import "my-component-library/styles/quasar.global.sass" ++ // ^⚠️ WARNING^: Applies global styles to base elements (e.g. h1, h2). You may ++ // have to list your custom styles later to override these Quasar styles. + ++ import "my-component-library/styles/quasar.variables.sass" ++ import { setupQuasarApp } from "my-component-library/setupQuasarApp" + +const app = createApp(App) ++ setupQuasarApp(app) +app.mount(#app) +``` +{: file='your-vue-app/main.ts' } + +### Add a custom component based on Quasar + +For this tutorial let's create a custom DatePicker + +Create a `DatePicker.vue` +```vue + + + +``` +{: file='lib/components/DatePicker.vue' } + +### Render and test Quasar components in Storybook.js + +Create some stories for the DatePicker +```typescript +import type { Meta, StoryObj } from '@storybook/vue3'; +import { userEvent, within } from '@storybook/testing-library'; + +import DatePicker from '../components/DatePicker.vue'; + +// CONFIGURATION +const meta = { + title: "Date Picker", + component: DatePicker, + render(args, { argTypes }) { + return { + template: `
+ Selected Date (in parent): {{ args.modelValue }} +
+
+ +
`, + props: Object.keys(argTypes), + components: { DatePicker }, + data: () => ({ args }), + } + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// STORIES +export const Default: Story = { + args: {}, + play: async ({ canvasElement }) => { + const datePicker = within(canvasElement).getByRole('datepicker'); + await userEvent.click(within(datePicker).getByText("15")); + await userEvent.click(within(datePicker).getByText("Today")); + } +} + +export const SpecificDate: Story = { + args: { + modelValue: "2022-05-23", + }, +} +``` +{: file='lib/components/DatePicker.stories.ts' } + +And update the Storybook Previewer to support our Quasar components +```diff +import type { Preview } from "@storybook/vue3"; + ++ import { setup } from '@storybook/vue3'; ++ import { setupQuasarApp } from '../lib/setupQuasarApp'; ++ import "../lib/styles/quasar.global.sass" + ++ setup(setupQuasarApp) + +const preview: Preview = { + … +``` +{: file='.storybook/preview.ts' } + +## Conclusion + +Quasar is a fabulous tool for building Vue applications. Hopefully this tutorial has helped it become even more useful and testable! + +Feel free to comment on your experience improving the testability of Quasar and/or using it in a custom component library! \ No newline at end of file -- 2.51.2