This guide helps set up a vue 3 project with the most commonly used libraries. It includes api (REST
and GraphQL
), mock api
, authentication
, design libraries
, authentication
, i18n
and storybook
.
The file structure of create vue
is kept, except for the plugins, which are moved to a dedicated folder.
*is optional
- vsc extensions
- create vue
- prettier
- .env files
- typescript
- plugins
- webfonts*
- sass
- axios
- example files
- vuetify*
- ant design*
- tailwind css*
- pinia*
- i18n*
- vue query*
- msw*
- graphql*
- unit tests*
- e2e tests*
- storybook*
- authentication*
- other libraries
- unused files
- folder structure
- import order
- vue query response type and refactor graphql example
- folder and file naming
- write better test and align with storybook
- fix playwright chromium with skysea (remove the
--disable-extensions
switch when launching chromium) - fix playwright firefox not using msw
- test build
- prettier differences with autoformat
- storybook explanations in readme
- fix tsconfig "class" error when adding .storybook to the includes
- refactor roles update in storybook
- pinia shared state
Cannot stringify arbitrary non-POJOs
error
npm create vue@3
Use the following settings:
- Add TypeScript? Yes
- Add JSX Support? Yes*
- Add Vue Router for Single Page Application development? Yes
- Add Pinia for state management? Yes*
- Add Vitest for Unit Testing? Yes*
- Add an End-to-End Testing Solution? » - Use arrow-keys. Return to submit. Playwright*
- Add ESLint for code quality? Yes
- Add Prettier for code formatting? Yes
cd project-name
npm install
npm run lint
npm run dev
It is possible that the following error occurs in the tsconfig.json
files
Option 'importsNotUsedAsValues' is deprecated and will stop functioning in TypeScript 5.5. Specify compilerOption '"ignoreDeprecations": "5.0"' to silence this error.
Use 'verbatimModuleSyntax' instead.
In this case, add to the affected files (see vuejs/tsconfig#6)
"compilerOptions": {
"preserveValueImports": false,
"importsNotUsedAsValues": "remove",
"verbatimModuleSyntax": true,
},
Update .prettierrc.json
{
"singleQuote": true,
"semi": false,
"trailingComma": "none",
"printWidth": 120
}
Add the following to .gitignore
+ # local env files
+ .env
Create .env
VITE_API_HOST=https://reqres.in/api/
Create .env.example
VITE_API_HOST=api base path
Create src/models/examples.types.ts
export type Example = {
id: number
name: string
year: number
color: string
pantone_value: string
}
export type ExamplesGetResponse = {
page: number
per_page: number
total: number
total_pages: number
data: Example[]
}
Create a separate plugins folder similar to vuetify cli
Create src/plugins/index.ts
// Plugins
import router from '@/router'
import { createPinia } from 'pinia'
// Styles
import '@/assets/main.css'
// Types
import type { App } from 'vue'
export function registerPlugins(app: App) {
app.use(router).use(createPinia())
}
Update src/main.ts
import { createApp } from 'vue'
- import { createPinia } from 'pinia'
import App from './App.vue'
- import router from './router'
+ import { registerPlugins } from '@/plugins'
- import './assets/main.css'
const app = createApp(App)
- app.use(createPinia())
- app.use(router)
+ registerPlugins(app)
app.mount('#app')
yarn add webfontloader; yarn add -D @types/webfontloader
Create src/plugins/webfontloader.ts
/**
* plugins/webfontloader.ts
*
* webfontloader documentation: https://github.com/typekit/webfontloader
*/
export async function loadFonts() {
const webFontLoader = await import(/* webpackChunkName: "webfontloader" */ 'webfontloader')
webFontLoader.load({
google: {
families: ['Roboto:100,300,400,500,700,900&display=swap']
}
})
}
Update src/plugins/index.ts
// Plugins
+ import { loadFonts } from './webfontloader'
export function registerPlugins(app: App) {
+ loadFonts()
yarn add -D sass sass-loader
Create src/assets/styles/styles.scss
@import 'main.css';
Move the following files
src/assets/base.css
=> src/assets/styles/base.css
src/assets/main.css
=> src/assets/styles/main.css
Update src/plugins/index.ts
- import '@/assets/main.css'
+ import '@/assets/styles/styles.scss'
yarn add axios
Create src/services/api.service.ts
import axios from 'axios'
// api without authorization header
export const oApi = axios.create({
baseURL: import.meta.env.VITE_API_HOST,
headers: { 'Content-Type': 'application/json' }
})
export default oApi
Create src/components/Example/ExampleComponent.vue
<template>
<div>
<h2>
{{ example.name }}
</h2>
<p>
<span :style="{ backgroundColor: example.color }" class="color" />{{
example.pantone_value
}}
</p>
</div>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import type { Example } from '@/models/examples.types'
defineProps({
example: {
type: Object as PropType<Example>,
required: true
}
})
</script>
<style lang="scss" scoped>
.color {
display: inline-block;
width: 1em;
height: 1em;
margin-right: 0.5em;
}
</style>
VSCode might show an error with the @
alias import. this can be solved with ctrl+shift+p
and Volar: Restart Vue server
Create src/views/ExampleView/ExampleView.vue
<template>
<div>
<h1>Example</h1>
<h2>Pinia</h2>
<div>count: {{ counter.count }}</div>
<button @click="counter.increment">Increment</button>
<h2>Api call</h2>
<div v-if="query.isLoading">Loading...</div>
<div v-else-if="query.isError">An error has occurred: {{ query.error }}</div>
<div v-if="query.data">
<ExampleComponent v-for="example in query.data" :key="example.id" :example="example" class="examples" />
</div>
</div>
</template>
<script lang="ts" setup>
import { onBeforeMount, ref } from 'vue'
import oApi from '@/services/api.service'
import type { ExamplesGetResponse } from '@/models/examples.types'
import ExampleComponent from '@/components/Example/ExampleComponent.vue'
import { useCounterStore } from '@/stores/counter'
import axios from 'axios'
const counter = useCounterStore()
const query = ref()
async function getExample() {
try {
query.value = { isLoading: true }
const { data } = await oApi.get<ExamplesGetResponse>(`${import.meta.env.VITE_API_HOST}examples`)
query.value = data
return data
} catch (error) {
if (axios.isAxiosError(error)) {
query.value = { isError: error.message }
console.log('error message: ', error.message)
return error.message
} else {
query.value = { isError: error }
console.log('unexpected error: ', error)
return 'An unexpected error occurred'
}
}
}
onBeforeMount(() => {
getExample()
})
</script>
Create src/views/NotFoundView/NotFoundView.vue
<template>
<div>
<h1>404 Error</h1>
</div>
</template>
<script lang="ts" setup />
Update src/router/index.ts
+ import NotFoundView from '@/views/NotFoundView/NotFoundView.vue'
[
- {
- path: '/about',
- name: 'about',
- // route level code-splitting
- // this generates a separate chunk (About.[hash].js) for this route
- // which is lazy-loaded when the route is visited.
- component: () => import('../views/AboutView.vue')
- }
+ {
+ path: '/example',
+ name: 'example',
+ component: () => import('@/views/ExampleView/ExampleView.vue')
+ },
+ {
+ path: '/:pathMatch(.*)*',
+ name: 'notFound',
+ component: NotFoundView
+ }
]
Update src/App.vue
- <script setup lang="ts">
- import { RouterLink, RouterView } from 'vue-router'
- import HelloWorld from './components/HelloWorld.vue'
- </script>
-
-<template>
- <header>
- <img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
-
- <div class="wrapper">
- <HelloWorld msg="You did it!" />
-
- <nav>
- <RouterLink to="/">Home</RouterLink>
- <RouterLink to="/about">About</RouterLink>
- </nav>
- </div>
- </header>
-
- <RouterView />
- </template>
+<template>
+ <header>
+ <div class="wrapper">
+ <nav>
+ <router-link to="/">Home</router-link>
+ <router-link to="/example">Example</router-link>
+ </nav>
+ </div>
+ </header>
+ <div class="fade_container">
+ <router-view v-slot="{ Component }">
+ <transition name="fade">
+ <component :is="Component" :key="$route.path" />
+ </transition>
+ </router-view>
+ </div>
+</template>
+
+<script setup lang="ts" />
Create src/assets/styles/_transitions.scss
.fade_container {
display: flex;
> * {
width: 100%;
flex-shrink: 0;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-active:not(:first-child) {
margin-left: -100%;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
Update src/assets/styles/styles.scss
+ @import 'transitions';
yarn add vuetify@next @mdi/font; yarn add -D vite-plugin-vuetify
Create src/plugins/vuetify.ts
/**
* plugins/vuetify.ts
*
* Framework documentation: https://vuetifyjs.com`
*/
// Styles
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
// Composables
import { createVuetify } from 'vuetify'
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
themes: {
light: {
colors: {
primary: '#1867C0',
secondary: '#5CBBF6'
}
}
}
}
})
Update src/plugins/index.ts
+ import vuetify from './vuetify'
import router from '@/router'
app
+ .use(vuetify)
Replace src/App.vue
<template>
<v-app>
<v-toolbar>
<v-btn :to="{ name: 'home' }" exact>Home</v-btn>
<v-btn :to="{ name: 'example' }">Example</v-btn>
</v-toolbar>
<v-main class="fade_container">
<router-view v-slot="{ Component }">
<transition name="fade">
<component :is="Component" :key="$route.path" />
</transition>
</router-view>
</v-main>
</v-app>
</template>
<script setup lang="ts" />
Update vite.config.ts
import vueJsx from '@vitejs/plugin-vue-jsx'
+ import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
plugins: [
- vue(),
+ vue({
+ template: { transformAssetUrls }
+ }),
vueJsx(),
+ vuetify({
+ autoImport: true
+ })
],
Update src/views/ExampleView/ExampleView.vue
- <button @click="counter.increment">Increment</button>
+ <v-btn @click="counter.increment">Increment</v-btn>
yarn add ant-design-vue; yarn add -D less
Update src/plugins/index.ts
+ import 'ant-design-vue/dist/antd.less'
import '@/assets/styles/styles.scss'
Update vite.config.ts
+ css: {
+ preprocessorOptions: {
+ less: {
+ modifyVars: {
+ 'primary-color': '#1DA57A'
+ },
+ javascriptEnabled: true
+ }
+ }
+}
yarn add -D tailwindcss@latest postcss@latest autoprefixer@latest
npx tailwindcss init -p
Create src/assets/styles/tailwind.css
@tailwind base;
@tailwind components;
@tailwind utilities;
Update src/plugins/index.ts
+ import '@/assets/styles/tailwind.css'
import '@/assets/styles/styles.scss'
Update postcc.config.js
+ /* eslint-env node */
module.exports = {
Update tailwind.config.js
+ /* eslint-env node */
/** @type {import('tailwindcss').Config} */
- content: [],
+ content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
yarn add pinia-shared-state
Create src/plugins/pinia.ts
import { createPinia } from 'pinia'
import { PiniaSharedState } from 'pinia-shared-state'
export const pinia = createPinia().use(PiniaSharedState({}))
export default pinia
Update src/plugins/index.ts
- import { createPinia } from 'pinia'
+ import pinia from './pinia'
- .use(createPinia())
+ .use(pinia)
yarn add vue-i18n
Update tsconfig.app.json
"include": [
+ "src/**/*.json",
],
Create src/locales/en.json
{
"header": {
"home": "Home",
"example": "Example"
}
}
Create src/locales/ja.json
{
"header": {
"home": "トップ",
"example": "例"
}
}
Create src/plugins/i18n.ts
import { createI18n } from 'vue-i18n'
import en from '@/locales/en.json'
import ja from '@/locales/ja.json'
export const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: {
en,
ja
}
})
export default i18n
Update src/plugins/index.ts
import { createPinia } from 'pinia'
+ import i18n from './i18n'
.use(pinia)
+ .use(i18n)
Update src/App.vue
- <router-link to="/">Home</router-link>
- <router-link to="/example">Example</router-link>
+ <router-link to="/">{{ $t('header.home') }}</router-link>
+ <router-link to="/example">{{ $t('header.example') }}</router-link>
...
+ <select v-model="$i18n.locale">
+ <option v-for="(locale, index) in $i18n.availableLocales" :key="index" :value="locale"
+ :variant="$i18n.locale === locale ? 'tonal' : 'plain'" @click="$i18n.locale = locale">
{{ locale }}
</option>
+ </select>
+ <v-menu>
+ <template v-slot:activator="{ props }">
+ <v-btn icon="mdi-translate" v-bind="props"></v-btn>
+ </template>
+ <v-list>
+ <v-list-item
+ v-for="(locale, index) in $i18n.availableLocales"
+ :key="index"
+ :value="locale"
+ :variant="$i18n.locale === locale ? 'tonal' : 'plain'"
+ @click="$i18n.locale = locale"
+ >
+ <v-list-item-title>{{ locale }}</v-list-item-title>
+ </v-list-item>
+ </v-list>
+ </v-menu>
+ </v-toolbar>
<v-btn :to="{ name: 'example' }">{{ $t('header.example') }}</v-btn>
+ <v-spacer></v-spacer>
+ <v-select v-model="$i18n.locale" :items="$i18n.availableLocales" label="i18n" density="compact"></v-select>
yarn add @tanstack/vue-query
Create src/plugins/vuequery.ts
import { VueQueryPlugin } from '@tanstack/vue-query'
export const vueQuery = (): [any, any] => {
return [
VueQueryPlugin,
{
queryClientConfig: {
defaultOptions: {
// Set default options if necessary
}
}
}
]
}
export default vueQuery
Update src/plugins/index.ts
// Plugins
+ import vueQuery from './vuequery'
.use(pinia)
+ .use(...vueQuery())
Replace src/views/ExampleView/ExampleView.vue
<template>
<div>
<h1>Example</h1>
<h2>Pinia</h2>
<div>count: {{ counter.count }}</div>
<button @click="counter.increment">Increment</button>
<h2>Api call</h2>
<!-- Loading -->
<div v-if="query?.isLoading">Loading...</div>
<!-- Error -->
<div v-else-if="query?.isError">An error has occurred: {{ query.error }}</div>
<!-- Result -->
<div v-else-if="query?.data">
<ExampleComponent v-for="example in query.data.data" :key="example.id" :example="example" class="examples" />
</div>
</div>
</template>
<script lang="ts" setup>
import { onBeforeMount, ref } from 'vue'
import oApi from '@/services/api.service'
import { useQuery } from '@tanstack/vue-query'
import type { QueryObserverResult } from '@tanstack/vue-query'
import type { ExamplesGetResponse } from '@/models/examples.types'
import ExampleComponent from '@/components/Example/ExampleComponent.vue'
import { useCounterStore } from '@/stores/counter'
/*
The commented out code has the following issue:
The `useQuery` type is `UseQueryReturnType` but causes the template to expect
`query.data.value` instead of `query.data.data` as is returned by `useQuery`
import type { UseQueryReturnType } from '@tanstack/vue-query'
const query = ref<UseQueryReturnType<ExamplesGetResponse, Error>>()
onBeforeMount(() => {
query.value = useQuery<ExamplesGetResponse, Error>({
queryKey: ['exampleFetch'],
queryFn: exampleFetch
})
})
*/
const counter = useCounterStore()
const query = ref<QueryObserverResult<ExamplesGetResponse, Error>>()
const exampleFetch = async (): Promise<ExamplesGetResponse[]> => {
try {
const response = await oApi.get(`${import.meta.env.VITE_API_HOST}examples`)
return response.data
} catch (error) {
console.error(error)
return Promise.reject(error)
}
}
onBeforeMount(() => {
query.value = useQuery({
queryKey: ['exampleFetch'],
queryFn: exampleFetch
}) as unknown as QueryObserverResult<ExamplesGetResponse, Error>
})
</script>
With Vuetify, update src/views/ExampleView/ExampleView.vue
- <button @click="counter.increment">Increment</button>
+ <v-btn @click="counter.increment">Increment</v-btn>
yarn add -D msw
Update tsconfig.app.json
{
"include": [
+ ".mocks/**/*",
+ ".mocks/**/*.json"
]
"compilerOptions": [
"paths": [
+ "@mocks/*": [
+ ".mocks/*"
+ ]
]
],
}
Update vite.config.ts
{
resolve: [
alias: [
+ '@mocks': fileURLToPath(new URL('./.mocks', import.meta.url))
]
]
}
Create .mocks/api/examples/examples.json
{
"page": 2,
"per_page": 6,
"total": 12,
"total_pages": 2,
"data": [
{
"id": 7,
"name": "sand dollar",
"year": 2006,
"color": "#DECDBE",
"pantone_value": "13-1106"
},
{
"id": 8,
"name": "chili pepper",
"year": 2007,
"color": "#9B1B30",
"pantone_value": "19-1557"
},
{
"id": 9,
"name": "blue iris",
"year": 2008,
"color": "#5A5B9F",
"pantone_value": "18-3943"
},
{
"id": 10,
"name": "mimosa",
"year": 2009,
"color": "#F0C05A",
"pantone_value": "14-0848"
},
{
"id": 11,
"name": "turquoise",
"year": 2010,
"color": "#45B5AA",
"pantone_value": "15-5519"
},
{
"id": 12,
"name": "honeysuckle",
"year": 2011,
"color": "#D94F70",
"pantone_value": "18-2120"
}
]
}
Create .mocks/api/examples/examples.ts
import { rest } from 'msw'
import type { PathParams } from 'msw'
import type { ExamplesGetResponse } from '@/models/examples.types'
import examples from './examples.json'
export const examplesGetHandler = rest.get<
object,
PathParams,
ExamplesGetResponse
>(`${import.meta.env.VITE_API_HOST}examples`, (req, res, ctx) =>
res(ctx.status(200), ctx.delay(500), ctx.json(examples))
)
export default examplesGetHandler
Create .mocks/handlers.ts
import { examplesGetHandler } from './api/examples/examples'
// List of api mocks to always load
export const handlers = [examplesGetHandler]
// List of api mocks to only load if the VITE_LOCAL_MOCKS env is true
export const localHandlers = []
export const allHandlers = [...handlers, ...localHandlers]
export default handlers
This follows this vite example. However the delay in starting msw might cause the first api calls to not be intercepted.
Create .mocks/browser.ts
import { setupWorker } from 'msw'
import { handlers, allHandlers } from '@mocks/handlers'
const workerHandlers = import.meta.env.VITE_LOCAL_MOCKS === 'true' ? allHandlers : handlers
export const worker = setupWorker(...workerHandlers)
Update src/main.ts
+ if (process.env.NODE_ENV === 'development') {
+ (async () => {
+ const { worker } = await import('@mocks/browser')
+ worker.start({ onUnhandledRequest: 'bypass' })
+ })()
+ }
Update src/main.ts
+ import { setupWorker } from 'msw'
+ import { handlers, allHandlers } from '@mocks/handlers'
+ if (process.env.NODE_ENV === 'development') {
+ let workerHandlers = handlers
+ if (import.meta.env.VITE_LOCAL_MOCKS) {
+ workerHandlers = allHandlers
+ }
+ setupWorker(...workerHandlers).start({ onUnhandledRequest: 'bypass' })
+}
Update .env
+ VITE_LOCAL_MOCKS=false
Create .env.msw
VITE_API_HOST=https://reqres.in/api/
VITE_LOCAL_MOCKS=true
Update .env.example
VITE_API_HOST=api base path
+ VITE_LOCAL_MOCKS=always use mocks when doing api calls
Update .gitignore
.env
+ .env.msw
npx msw init public/ --save
Update package.json
{
"scripts": {
+ "msw": "vite --mode msw",
}
}
Launching with yarn msw
will always use the mocks for api calls
Uses vue-query and graphql-request
yarn add graphql-request
Update .env
+ VITE_GRAPHQL_ENDPOINT=https://swapi-graphql.netlify.app/.netlify/functions/index
Update src/models/examples.types.ts
+ export type ExampleGraphQL = {
+ title: string
+ releaseDate: string
+ }
+
+ export type ExamplesGraphQLResponse = {
+ allFilms: {
+ films: ExampleGraphQL[]
+ }
+ }
Create src/views/ExampleGraphQLView/ExampleGraphQLView.vue
<template>
<div>
<h1>GraphQL</h1>
<!-- Loading -->
<div v-if="query?.isLoading">Loading...</div>
<!-- Error -->
<div v-else-if="query?.isError">An error has occurred: {{ query.isError }}</div>
<!-- Result -->
<div v-else-if="query?.data">
<ul>
<li v-for="film of query.data.allFilms.films" :key="film.releaseDate">
{{ film.releaseDate }} {{ film.title }}
</li>
</ul>
</div>
<!-- No result -->
<div v-else>No result</div>
</div>
</template>
<script lang="ts" setup>
import { onBeforeMount, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import type { QueryObserverResult } from '@tanstack/vue-query'
import { request, gql } from 'graphql-request'
import type { ExamplesGraphQLResponse } from '@/models/examples.types'
/*
The `useQuery` type is `UseQueryReturnType` but causes the template to expect
`query.data.value` instead of `query.data.allFilms` as is returned by `useQuery`
import type { UseQueryReturnType } from '@tanstack/vue-query'
const query = ref<UseQueryReturnType<ExamplesGraphQLResponse, Error>>()
onBeforeMount(() => {
query.value = useQuery<ExamplesGraphQLResponse, Error>({
queryKey: ['ExampleFilms'],
queryFn: async () => request(import.meta.env.VITE_GRAPHQL_ENDPOINT, EXAMPLES_QUERY)
})
})
*/
const query = ref<QueryObserverResult<ExamplesGraphQLResponse, Error>>()
const EXAMPLES_QUERY = gql`
query ExampleFilms {
allFilms {
films {
title
releaseDate
}
}
}
`
onBeforeMount(() => {
query.value = useQuery<ExamplesGraphQLResponse, Error>({
queryKey: ['ExampleFilms'],
queryFn: async () => request(import.meta.env.VITE_GRAPHQL_ENDPOINT, EXAMPLES_QUERY)
}) as unknown as QueryObserverResult<ExamplesGraphQLResponse, Error>
})
</script>
Update src/router/index.ts
+ {
+ path: '/graphql',
+ name: 'graphql',
+ component: () => import('@/views/ExampleGraphQLView/ExampleGraphQLView.vue')
+ }
Update src/App.vue
+ <router-link to="/graphql">GraphQL</router-link>
+ <v-btn :to="{ name: 'graphql' }">GraphQL</v-btn>
Create .mocks/graphql/exampleFilms/exampleFilms.json
{
"allFilms": {
"films": [
{
"title": "A New Hope",
"releaseDate": "1977-05-25"
},
{
"title": "The Empire Strikes Back",
"releaseDate": "1980-05-17"
},
{
"title": "Return of the Jedi",
"releaseDate": "1983-05-25"
}
]
}
}
Create .mocks/graphql/exampleFilms/exampleFilms.ts
import type { ExamplesGraphQLResponse } from '@/models/examples.types'
import { graphql } from 'msw'
import exampleFilms from './exampleFilms.json'
export const exampleFilmsHandler = graphql.query<ExamplesGraphQLResponse>('ExampleFilms', (req, res, ctx) => {
return res(
ctx.delay(500),
ctx.data(exampleFilms)
)
})
export default exampleFilmsHandler
Update .mocks/handlers.ts
+ import { exampleFilmsHandler } from './graphql/exampleFilms/exampleFilms'
export const handlers = [
+ exampleFilmsHandler
]
Create src/components/Example/ExampleComponent.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ExampleComponent from './ExampleComponent.vue'
describe('ExampleComponent', () => {
it('renders properly', () => {
const wrapper = mount(ExampleComponent, {
props: {
example: {
id: 7,
name: 'sand dollar',
year: 2006,
color: '#DECDBE',
pantone_value: '13-1106'
}
}
})
expect(wrapper.text()).toContain('sand dollar13-1106')
})
})
Update src/components/Example/ExampleComponent.spec.ts
+ import examples from '@mocks/api/examples/examples.json'
- example: {
- id: 1,
- name: 'cerulean',
- year: 2000,
- color: '#98B2D1',
- pantone_value: '15-4020'
- }
+ example: examples.data[0]
- expect(wrapper.text()).toContain('sand dollar13-1106')
+ expect(wrapper.text()).toContain(`${examples.data[0].name}${examples.data[0].pantone_value}`)
npx playwright install
Update e2e/tsconfig.json
.mocks and .storybook includes and paths are optional
"include": [
"./**/*",
+ "../env.d.ts",
+ "../src/**/*",
+ "../src/**/*.vue",
+ "../src/**/*.json",
+ "../.mocks/**/*",
+ "../.mocks/**/*.json",
+ "../.storybook/**/*"
],
+ "compilerOptions": {
+ "rootDir": "../",
+ "composite": true,
+ "baseUrl": "../",
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ],
+ "@mocks/*": [
+ ".mocks/*"
+ ],
+ "@stb/*": [
+ ".storybook/*"
+ ]
+ }
+ }
}
Create e2e/example/ExampleFlow.spec.ts
import { test, expect } from '@playwright/test'
// See here how to get started:
// https://playwright.dev/docs/intro
test('visits the app root url', async ({ page }) => {
await page.goto('/example')
await expect(page.locator('div.examples h2').first()).toHaveText('sand dollar')
})
Update e2e/example/ExampleFlow.spec.ts
+ import examples from '@mocks/api/examples/examples.json'
- await expect(page.locator('div.examples h2').first()).toHaveText('sand dollar')
+ await expect(page.locator('div.examples h2').first()).toHaveText(`${examples.data[0].name}`)
Guide: Storybook 7 を Vue 3 + TypeScript ではじめよう!(Japanese only)
npx storybook@latest init
- Ok to proceed? (y) y
- Do you want to run the 'eslintPlugin' migration on your project? y
Update tsconfig.app.json
{
"include": [
+ ".storybook/**/*",
]
"compilerOptions": [
"paths": [
+ "@stb/*": [
+ ".storybook/*"
+ ]
]
],
}
In case "class" in vue templates throws an error, also add the following`
{
"compilerOptions": {
"types": [
+ "vite/client"
]
}
}
Update vite.config.ts
resolve: {
alias: {
+ '@stb': fileURLToPath(new URL('./.storybook', import.meta.url))
}
},
Create src/components/Example/ExampleComponent.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import ExampleComponent from './ExampleComponent.vue'
const meta: Meta<typeof ExampleComponent> = {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/7.0/vue/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: 'Examples/Component',
component: ExampleComponent,
render: (args: any) => ({
components: { ExampleComponent },
setup() {
return { args }
},
template: '<example-component :example="args.example" />'
}),
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof ExampleComponent>
export const Default: Story = {
args: {
example: {
id: 7,
name: 'sand dollar',
year: 2006,
color: '#DECDBE',
pantone_value: '13-1106'
}
}
}
Modify .storybook/preview.ts
+ import { setup } from '@storybook/vue3'
+ import { registerPlugins } from '@/plugins'
+
+ setup((app: any) => {
+ // Registers your app's plugins into Storybook
+ registerPlugins(app)
+ });
+ export const decorators = [
+ (story: any) => {
+ return { template: '<div id="app"><story /></div>' }
+ }
+]
It is possible that src/stories/Button.vue
causes an error, in that case the src/stories folder can be deleted
Create src/views/ExampleView/ExampleView.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import router from '@/router'
import ExampleView from './ExampleView.vue'
import App from '@/App.vue'
const meta: Meta<typeof ExampleView> = {
title: 'Examples/View',
component: ExampleView,
render: () => ({
components: { ExampleView },
template: '<example-view />'
}),
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof ExampleView>
export const Page: Story = {
render: () => ({
components: { ExampleView, App },
setup() {
router.replace('/example')
},
template: '<app><example-view /></app>'
}),
parameters: {
layout: 'fullscreen'
}
}
export const Default: Story = {}
Create src/views/NotFoundView/NotFoundView.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import router from '@/router'
import NotFoundView from './NotFoundView.vue'
import App from '@/App.vue'
const meta: Meta<typeof NotFoundView> = {
title: 'Views/Not Found',
component: NotFoundView,
render: () => ({
components: { NotFoundView },
template: '<not-found-view />'
})
}
export default meta
type Story = StoryObj<typeof NotFoundView>
export const Page: Story = {
render: () => ({
components: { NotFoundView, App },
setup() {
router.replace('/404')
},
template: '<app><not-found-view /></app>'
}),
parameters: {
layout: 'fullscreen'
}
}
export const Default: Story = {}
Update src/plugins/vuequery.ts
- export const pinia = createPinia().use(PiniaSharedState({}))
+ export const pinia = (isStorybook: boolean): any => {
+ const pinia = createPinia()
+ if (!isStorybook) {
+ pinia.use(PiniaSharedState({}))
+ }
+ return pinia
+ }
Update src/plugins/index.ts
- export function registerPlugins(app: App) {
+ export function registerPlugins(app: App, isStorybook = false) {
- .use(pinia)
+ .use(pinia(isStorybook))
}
Update .storybook/preview.ts
- registerPlugins(app)
+ registerPlugins(app, true)
Update src/views/ExampleView/ExampleView.stories.ts
+ import { useCounterStore } from '@/stores/counter'
+ export const WithInitialCountOf10: Story = {
+ render: () => ({
+ components: { ExampleView },
+ setup() {
+ const counter = useCounterStore()
+ counter.count = 10
+ return {}
+ },
+ template: '<example-view />'
+ })
+}
Update .storybook/preview.ts
+ import type { StoryContext } from '@storybook/vue3'
+ import i18n from '@/plugins/i18n'
+ export const globalTypes = {
+ locale: {
+ name: 'Locale',
+ description: 'Internationalization',
+ defaultValue: 'en',
+ toolbar: {
+ icon: 'globe',
+ items: i18n.global.availableLocales
+ }
+ }
+}
- (story: any) => {
+ (story: any, context: StoryContext) => {
+ i18n.global.locale = context.globals.locale
This prevents vue query to retry on fail.
Update src/plugins/vuequery.ts
- export const vueQuery = (): [any, any] => {
+ export const vueQuery = (isStorybook: boolean): [any, any] => {
+ return [
+ VueQueryPlugin,
+ {
+ queryClientConfig: {
+ defaultOptions: {
+ queries: {
+ retry: !isStorybook
+ }
+ }
+ }
+ }
+ ]
Update src/plugins/index.ts
- export function registerPlugins(app: App) {
+ export function registerPlugins(app: App, isStorybook = false) {
- .use(...vueQuery)
+ .use(...vueQuery(isStorybook))
}
Update .storybook/preview.ts
import { registerPlugins } from '../src/plugins'
+ import { QueryClient } from '@tanstack/vue-query'
+
+ const queryClient = new QueryClient()
- registerPlugins(app)
+ registerPlugins(app, true)
+ // cancel queries when navigating between stories
+ queryClient.cancelQueries()
yarn add -D msw-storybook-addon
Update .storybook/preview.ts
+ import { initialize, mswDecorator } from 'msw-storybook-addon'
+ import { allHandlers } from '@mocks/handlers'
+ // Initialize MSW
+ initialize({
+ onUnhandledRequest: 'bypass'
+ })
+
export const decorators = [
+ mswDecorator,
export const parameters = {
+ msw: {
+ handlers: allHandlers
+ }
}
Update /src/views/ExampleView/ExampleView.stories.ts
import router from '@/router'
+ import { rest } from 'msw'
+ import examples from '@mocks/api/examples/examples.json'
export const Default: Story = {}
+ export const OneResult: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ rest.get(
+ `${import.meta.env.VITE_API_HOST}examples`,
+ (req, res, ctx) => {
+ return res(
ctx.status(200),
+ ctx.json({
+ ...examples,
+ total: 1,
+ data: examples.data.slice(0, 1)
+ })
+ )
+ }
+ )
+ ]
+ }
+ }
+ }
+
+ export const Loading: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ rest.get(
+ `${import.meta.env.VITE_API_HOST}examples`,
+ (req, res, ctx) => {
+ return res(ctx.status(200), ctx.delay(999999), ctx.json({}))
+ }
+ )
+ ]
+ }
+ }
+ }
+
+ export const Error: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ rest.get(
+ `${import.meta.env.VITE_API_HOST}examples`,
+ (req, res, ctx) => {
+ return res(ctx.status(404))
+ }
+ )
+ ]
+ }
+ }
+ }
Update src/components/Example/ExampleComponent.stories.ts
+ import examples from '@mocks/api/examples/examples.json'
- example: {
- id: 7,
- name: 'sand dollar',
- year: 2006,
- color: '#DECDBE',
- pantone_value: '13-1106'
- }
+ example: examples.data[0]
Create src/views/ExampleGraphQLView/ExampleGraphQLView.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import router from '@/router'
import { graphql } from 'msw'
import exampleFilms from '@mocks/graphql/exampleFilms/exampleFilms.json'
import ExampleGraphQLView from './ExampleGraphQLView.vue'
import App from '@/App.vue'
const meta: Meta<typeof ExampleGraphQLView> = {
title: 'Examples/GraphQLView',
component: ExampleGraphQLView,
render: () => ({
components: { ExampleGraphQLView },
template: '<example-graph-q-l-view />'
}),
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof ExampleGraphQLView>
export const Page: Story = {
render: () => ({
components: { ExampleGraphQLView, App },
setup() {
router.replace('/graphql')
},
template: '<app><example-graph-q-l-view /></app>'
}),
parameters: {
layout: 'fullscreen'
}
}
export const Default: Story = {}
export const OneResult: Story = {
parameters: {
msw: {
handlers: [
graphql.query('ExampleFilms', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.data({
allFilms: {
films: exampleFilms.allFilms.films.slice(0, 1)
}
})
)
})
]
}
}
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [
graphql.query('ExampleFilms', (req, res, ctx) => {
return res(ctx.status(200), ctx.delay(999999), ctx.data({}))
})
]
}
}
}
export const Error: Story = {
parameters: {
msw: {
handlers: [
graphql.query('ExampleFilms', (req, res, ctx) => {
return res(ctx.status(404))
})
]
}
}
}
yarn add -D @storybook/testing-library @storybook/jest @storybook/test-runner
Update .storybook/main.ts
stories: [
'../src/**/*.stories.@(js|jsx|ts|tsx)'
+ '../e2e/**/*.stories.@(js|jsx|ts|tsx)'
],
Create e2e/example/ExampleFlow.stories.tsx
import type { Meta, StoryObj } from '@storybook/vue3'
import router from '@/router'
import { within, waitFor, userEvent } from '@storybook/testing-library'
import { expect } from '@storybook/jest'
import App from '@/App.vue'
const meta: Meta<typeof App> = {
title: 'Examples/Flow',
component: App,
render: () => ({
components: { App },
setup() {
router.replace('/')
},
template: '<app />'
}),
parameters: {
layout: 'fullscreen'
}
}
export default meta
type Story = StoryObj<typeof App>
export const Default: Story = {}
/*
* See https://storybook.js.org/docs/7.0/react/writing-stories/play-function#working-with-the-canvas
* to learn more about using the canvasElement to query the DOM
*/
export const Navigate: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await waitFor(async () => {
await expect(canvas.getByText('Example'))
})
const exampleLink = await canvas.getByText('Example')
await userEvent.click(exampleLink)
}
}
Update package.json
"scripts": {
+ "test-storybook": "test-storybook"
},
Authentication boilerplate:
- Pinia is used for the login state, tokens and user role
- REST with authorization header
- Token and refresh token (saved in local storage)
- Route guards
- Multiple user roles
- Implementation in storybook with a role menu in the toolbar
Create src/constants/auth.constants.ts
export enum RolesEnum {
VISITOR = '',
USER = 'user',
ADMIN = 'admin'
}
export enum AccessDeniedPageErrorEnum {
OK = '',
NOT_LOGGED_IN = 'notLoggedIn',
INSUFFICIENT_PERMISSIONS = 'insufficientPermissions'
}
Create src/models/auth.types.ts
import { AccessDeniedPageErrorEnum, RolesEnum } from '@/constants/auth.constants'
import type { RouteRecordName } from 'vue-router'
export class LoginRequest {
email?: string
password?: string
constructor() {
this.email = ''
this.password = ''
}
}
export class Tokens {
token: string
refreshToken: string
constructor() {
this.token = ''
this.refreshToken = ''
}
}
export type LoginPostResponse = Tokens
export type RefreshPostResponse = Tokens
export class User {
email: string
name: string
role: RolesEnum
constructor() {
this.email = ''
this.name = ''
this.role = RolesEnum.VISITOR
}
}
export type UserGetResponse = User
export class AccessDeniedPage {
name: RouteRecordName | null | undefined
error: AccessDeniedPageErrorEnum
constructor(name: RouteRecordName | null | undefined = null, error = AccessDeniedPageErrorEnum.OK) {
this.name = name
this.error = error
}
}
Update src/models/examples.types.ts
+ export type ExampleLoggedInGetResponse = {
+ message: string
+ }
Create src/stores/auth.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { AccessDeniedPage, Tokens, User } from '@/models/auth.types'
export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = ref(false)
const isRefreshing = ref(false)
const isFetchingUserData = ref(false)
const accessDeniedPage = ref(new AccessDeniedPage())
const tokens = ref(new Tokens())
const user = ref<User>()
function setTokens(tokensValue: Tokens) {
tokens.value = tokensValue
}
function setUser(userValue: User) {
user.value = userValue
}
function setLoggedIn(isLoggedInValue: boolean) {
isLoggedIn.value = isLoggedInValue
if (!isLoggedInValue) {
tokens.value = new Tokens()
user.value = new User()
}
}
function setRefreshing(isRefreshingValue: boolean) {
isRefreshing.value = isRefreshingValue
}
function setFetchingUserData(isFetchingUserDataValue: boolean) {
isFetchingUserData.value = isFetchingUserDataValue
}
function setAccessDeniedPage(accessDeniedPageValue: AccessDeniedPage) {
accessDeniedPage.value = accessDeniedPageValue
}
return {
accessDeniedPage,
isFetchingUserData,
isLoggedIn,
isRefreshing,
tokens,
user,
setAccessDeniedPage,
setFetchingUserData,
setLoggedIn,
setRefreshing,
setTokens,
setUser
}
})
Replace src/services/api.service.ts
import axios, { AxiosError } from 'axios'
import { useAuthStore } from '@/stores/auth'
import { logout, setLocalStorage } from './auth.service'
// api without authorization header
export const oApi = axios.create({
baseURL: import.meta.env.VITE_API_HOST,
headers: { 'Content-Type': 'application/json' }
})
// api with authorization header
const api = axios.create({
baseURL: import.meta.env.VITE_API_HOST,
headers: { 'Content-Type': 'application/json' }
})
// add authorization header
api.interceptors.request.use(
(config) => {
if (config?.headers) {
const auth = useAuthStore()
config.headers.Authorization = `Bearer ${auth.tokens.token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// intercept response for error handling
api.interceptors.response.use(
(response) => {
return response
},
(error: AxiosError) => {
console.error('api error', { error })
const auth = useAuthStore()
const originalRequest = error.config
if (!originalRequest) {
throw error
}
if (error.response?.status === 401) {
if (!auth.tokens?.refreshToken) {
logout()
throw error
}
// call token refresh api
if (!auth.isRefreshing) {
auth.setRefreshing(true)
return oApi
.post(`${import.meta.env.VITE_API_HOST}refresh`, {
refreshToken: `Basic ${auth.tokens.refreshToken}`
})
.then((res: any) => {
// update tokens and recall original request
auth.setTokens(res.data)
setLocalStorage()
api.defaults.headers.common['Authorization'] = 'Bearer ' + res.data.token
return api(originalRequest)
})
.catch((error: any) => {
logout()
throw error
})
.finally(() => {
auth.setFetchingUserData(false)
})
} else {
// recall original request when token has been refreshed
return new Promise((resolve, reject) => {
const unsubscribe = auth.$subscribe(() => {
if (!auth.isRefreshing) {
unsubscribe()
if (auth.isLoggedIn) {
return api(originalRequest)
}
throw error
}
})
})
}
}
}
)
export default api
Create src/services/auth.service.ts
import { AccessDeniedPage, type LoginRequest } from '@/models/auth.types'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'
import api, { oApi } from '@/services/api.service'
export const login = (formData: LoginRequest) => {
const auth = useAuthStore()
return oApi
.post(`${import.meta.env.VITE_API_HOST}login`, formData)
.then((res: any) => {
auth.setTokens(res.data)
return fetchUserData(true)
})
.catch((error: any) => {
throw error
})
}
export const fetchUserData = (hasForward = false) => {
const auth = useAuthStore()
auth.setFetchingUserData(true)
return api
.get(`${import.meta.env.VITE_API_HOST}user`)
.then((res: any) => {
auth.setUser(res.data)
auth.setLoggedIn(true)
if (hasForward) {
const forwardTo = auth.accessDeniedPage.name || 'main'
auth.setAccessDeniedPage(new AccessDeniedPage())
router.push({ name: forwardTo })
}
return res
})
.catch((error: any) => {
throw error
})
.finally(() => {
auth.setFetchingUserData(false)
})
}
export const initAuth = () => {
const auth = useAuthStore()
if (!auth.user) {
getLocalStorage()
if (localStorage.getItem('isLoggedIn') === 'true') {
fetchUserData()
}
}
}
export const logout = () => {
const auth = useAuthStore()
auth.setLoggedIn(false)
setLocalStorage()
router.push({ name: 'home' })
}
export const setLocalStorage = () => {
const auth = useAuthStore()
localStorage.setItem('isLoggedIn', JSON.stringify(auth.isLoggedIn))
localStorage.setItem('tokens', JSON.stringify(auth.tokens))
}
export const getLocalStorage = () => {
const auth = useAuthStore()
auth.setLoggedIn(localStorage.getItem('isLoggedIn') === 'true')
auth.setTokens(JSON.parse(localStorage.getItem('tokens') || '{}'))
}
Create src/components/Login/LoginComponent.vue
<template>
<form ref="form" @submit.prevent="submitLogin">
<div>
<label for="email">Email</label>
<input v-model="loginRequest.email" label="email" id="email" name="email" type="email" required />
</div>
<div>
<label for="password">Password</label>
<input
v-model="loginRequest.password"
id="password"
name="password"
:type="isPasswordVisible ? 'text' : 'password'"
/>
<button type="button" @click="isPasswordVisible = !isPasswordVisible">
{{ isPasswordVisible ? 'hide password' : 'show password' }}
</button>
</div>
<button type="submit" :disabled="isLoading">{{ isLoading ? 'Loading...' : 'Login' }}</button>
<p v-if="auth.accessDeniedPage.error">{{ auth.accessDeniedPage.error }}</p>
</form>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { LoginRequest } from '@/models/auth.types'
import { login } from '@/services/auth.service'
const loginRequest = ref(new LoginRequest())
const isLoading = ref(false)
const isPasswordVisible = ref(false)
const submitLogin = () => {
isLoading.value = true
login(loginRequest.value).finally(() => {
isLoading.value = false
})
}
</script>
Create src/views/LoginView/LoginView.vue
<template>
<div>
<h1>Login</h1>
<LoginComponent />
</div>
</template>
<script lang="ts" setup>
import LoginComponent from '@/components/Login/LoginComponent.vue'
</script>
Create src/views/MainView/MainView.vue
<template>
<div>
<h1>Main</h1>
<p>{{ query?.data?.message }}</p>
</div>
</template>
<script lang="ts" setup>
import { onBeforeMount, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import type { QueryObserverResult } from '@tanstack/vue-query'
import type { ExampleLoggedInGetResponse } from '@/models/examples.types'
import api from '@/services/api.service'
const query = ref<QueryObserverResult<ExampleLoggedInGetResponse, Error>>()
const exampleLoggedInFetch = async (): Promise<ExampleLoggedInGetResponse[]> => {
try {
const response = await api.get('exampleLoggedIn')
console.log('response', response)
return response.data
} catch (error) {
console.error(error)
return Promise.reject(error)
}
}
onBeforeMount(() => {
query.value = useQuery({
queryKey: ['exampleLoggedInFetch'],
queryFn: exampleLoggedInFetch
}) as unknown as QueryObserverResult<ExampleLoggedInGetResponse, Error>
})
</script>
Update src/router/index.ts
+ import { AccessDeniedPageErrorEnum, RolesEnum } from '@/constants/auth.constants'
+ import type { RouteRecordName, RouteLocationNormalized } from 'vue-router'
+ import { AccessDeniedPage } from '@/models/auth.types'
+ import LoginView from '@/views/LoginView/LoginView.vue'
const router = createRouter({
+ {
+ path: '/login',
+ name: 'login',
+ component: LoginView
+ },
+ {
+ path: '/main',
+ name: 'main',
+ meta: { roles: [RolesEnum.USER] },
+ component: () => import('@/views/MainView/MainView.vue')
+ }
+ // Returns false if the user cannot access this route
+ export function canAccessRoute(name: RouteRecordName | RouteLocationNormalized) {
+ const auth = useAuthStore()
+ const to = typeof name === 'string' ? router.resolve({ name }) : (name as RouteLocationNormalized)
+ return !to.meta.roles || (auth.user?.role && (to.meta.roles as RolesEnum[])?.includes(auth.user.role))
+ }
+
+ router.beforeEach(async (to, from) => {
+ const auth = useAuthStore()
+ // wait if the user data is being fetched
+ if (to.meta.roles && auth.isFetchingUserData) {
+ await new Promise<void>((resolve, reject) => {
+ const unsubscribe = auth.$subscribe(() => {
+ if (!auth.isFetchingUserData) {
+ unsubscribe()
+ resolve()
+ }
+ })
+ })
+ }
+ if (!canAccessRoute(to)) {
+ const accessDeniedPage = new AccessDeniedPage(
+ to.name,
+ auth.isLoggedIn ? AccessDeniedPageErrorEnum.INSUFFICIENT_PERMISSIONS : AccessDeniedPageErrorEnum.NOT_LOGGED_IN
+ )
+ auth.setAccessDeniedPage(accessDeniedPage)
+ return { name: 'login' }
+ }
+ if (to.name !== 'login' && auth.accessDeniedPage.error) {
+ auth.setAccessDeniedPage(new AccessDeniedPage())
+ }
+ })
Update src/App.vue
+ <router-link v-if="canAccessRoute('main')" to="/main">Main</router-link>
+ <router-link v-if="!auth.isLoggedIn" :to="{ name: 'login' }">Login</router-link>
+ <button v-if="auth.isLoggedIn" @click="logout()">Logout</button>
- <script setup lang="ts" />
+ <script setup lang="ts">
+ import { useAuthStore } from '@/stores/auth'
+ import { initAuth, logout } from '@/services/auth.service'
+ import { canAccessRoute } from '@/router'
+ const auth = useAuthStore()
+ initAuth()
+ </script>
Replace template in src/components/Login/LoginComponent.vue
<template>
<v-form ref="form" @submit.prevent="submitLogin">
<v-text-field v-model="loginRequest.email" label="email" type="email" required></v-text-field>
<v-text-field
v-model="loginRequest.password"
:append-inner-icon="isPasswordVisible ? 'mdi-eye' : 'mdi-eye-off'"
:type="isPasswordVisible ? 'text' : 'password'"
name="password"
label="password"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
></v-text-field>
<v-btn type="submit" :loading="isLoading" :disabled="isLoading">Login</v-btn>
<p v-if="auth.accessDeniedPage.error">{{ auth.accessDeniedPage.error }}</p>
</v-form>
</template>
Update src/App.vue
- <router-link v-if="canAccessRoute('main')" to="/main">Main</router-link>
+ <v-btn v-if="canAccessRoute('main')" :to="{ name: 'main' }">Main</v-btn>
- <router-link v-if="!auth.isLoggedIn" :to="{ name: 'login' }">Login</router-link>
- <button v-if="auth.isLoggedIn" @click="logout()">Logout</button>
+ <v-btn v-if="!auth.isLoggedIn" :to="{ name: 'login' }" icon="mdi-login" title="Login"></v-btn>
+ <v-btn v-if="auth.isLoggedIn" @click="logout()" icon="mdi-logout" title="Logout"></v-btn>
Create .mocks/api/exampleLoggedIn/exampleLoggedIn.json
{
"message": "Logged in successfully"
}
Create .mocks/api/exampleLoggedIn/exampleLoggedIn.ts
import { rest } from 'msw'
import type { PathParams } from 'msw'
import type { ExampleLoggedInGetResponse } from '@/models/examples.types'
import exampleLoggedIn from './exampleLoggedIn.json'
export const exampleLoggedInGetHandler = rest.get<object, PathParams, ExampleLoggedInGetResponse>(
`${import.meta.env.VITE_API_HOST}exampleLoggedIn`,
(req, res, ctx) => {
return res(ctx.status(200), ctx.delay(500), ctx.json(exampleLoggedIn))
}
)
export default exampleLoggedInGetHandler
Create .mocks/api/login/login.json
{
"token": "token",
"refreshToken": "refreshToken"
}
Create .mocks/api/login/login.ts
import { rest } from 'msw'
import type { PathParams } from 'msw'
import login from './login.json'
import type { LoginPostResponse } from '@/models/auth.types'
export const loginPostHandler = rest.post<
object,
PathParams,
LoginPostResponse
>(`${import.meta.env.VITE_API_HOST}login`, (req, res, ctx) =>
res(ctx.status(200), ctx.delay(500), ctx.json(login))
)
export default loginPostHandler
Create .mocks/api/refresh/refresh.json
{
"token": "token",
"refreshToken": "refreshToken"
}
Create .mocks/api/refresh/refresh.ts
import { rest } from 'msw'
import type { PathParams } from 'msw'
import refresh from './refresh.json'
import type { RefreshPostResponse } from '@/models/auth.types'
export const refreshPostHandler = rest.post<
object,
PathParams,
RefreshPostResponse
>(`${import.meta.env.VITE_API_HOST}refresh`, (req, res, ctx) =>
res(ctx.status(200), ctx.delay(500), ctx.json(refresh))
)
export default refreshPostHandler
Create .mocks/api/user/user.json.ts
import { RolesEnum } from '@/constants/auth.constants'
export const user = {
email: 'abc123',
name: 'User Name',
role: RolesEnum.USER
}
Create .mocks/api/user/user.ts
import { rest } from 'msw'
import type { PathParams } from 'msw'
import type { UserGetResponse } from '@/models/auth.types'
import { user } from './user.json'
export const userGetHandler = rest.get<object, PathParams, UserGetResponse>(
`${import.meta.env.VITE_API_HOST}user`,
(req, res, ctx) => res(ctx.status(200), ctx.delay(500), ctx.json(user))
)
export default userGetHandler
Update .mocks/handlers.ts
+ import { exampleLoggedInGetHandler } from './api/exampleLoggedIn/exampleLoggedIn'
+ import { loginPostHandler } from './api/login/login'
+ import { refreshPostHandler } from './api/refresh/refresh'
+ import { userGetHandler } from './api/user/user'
export const handlers = [
+ loginPostHandler,
+ refreshPostHandler,
+ userGetHandler,
+ exampleLoggedInGetHandler,
At the time of writing Storybook 7 is still in beta, so @next
needs to be added.
yarn add -D @storybook/client-api@next
Storybook doesn't automatically refresh the story after changing the role, so this functionality is included here. These might not be necessary in future versions.
Update .storybook/preview.ts
+ import { useAuthStore } from '@/stores/auth'
+ import { RolesEnum } from '@/constants/auth.constants'
+ import { FORCE_REMOUNT } from '@storybook/core-events'
+ import { addons } from '@storybook/preview-api'
+ import { useStoryContext } from '@storybook/client-api'
export const globalTypes = {
+ role: {
+ name: 'role',
+ description: 'role',
+ defaultValue: 'visitor',
+ toolbar: {
+ icon: 'user',
+ items: Object.values(RolesEnum).map((item) => (item === '' ? 'visitor' : item))
+ }
+ let role = 'visitor'
export const decorators = [
+ if (role !== context.globals.role) {
+ role = context.globals.role
+ const parameters = useStoryContext()
+ addons.getChannel().emit(FORCE_REMOUNT, { storyId: parameters?.id })
+ }
- return { template: '<div id="app"><story /></div>' }
+ return {
+ setup() {
+ const auth = useAuthStore()
+ auth.setLoggedIn(context.globals.role === 'visitor' ? false : true)
+ auth.setUser({
+ email: 'abc123',
+ name: 'Story Book',
+ role: context.globals.role === 'visitor' ? '' : context.globals.role
+ })
+ },
+ template: `<div id="app"><story /></div>`
+ }
Create src/components/Login/LoginComponent.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import LoginComponent from './LoginComponent.vue'
const meta: Meta<typeof LoginComponent> = {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/7.0/vue/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: 'Components/Login',
component: LoginComponent,
render: (args: any) => ({
components: { LoginComponent },
setup() {
return { args }
},
template: '<login-component />'
}),
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof LoginComponent>
export const Default: Story = {}
Create src/views/LoginView/LoginView.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import router from '@/router'
import LoginView from './LoginView.vue'
import App from '@/App.vue'
const meta: Meta<typeof LoginView> = {
title: 'Views/Login',
component: LoginView,
render: () => ({
components: { LoginView },
template: '<login-view />'
}),
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof LoginView>
export const Page: Story = {
render: () => ({
components: { LoginView, App },
setup() {
router.replace('/login')
},
template: '<app><login-view /></app>'
}),
parameters: {
layout: 'fullscreen'
}
}
export const Default: Story = {}
Create src/components/Login/LoginComponent.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import router from '@/router'
import MainView from './MainView.vue'
import App from '@/App.vue'
const meta: Meta<typeof MainView> = {
title: 'Views/Main',
component: MainView,
render: () => ({
components: { MainView },
template: '<main-view />'
}),
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof MainView>
export const Page: Story = {
render: () => ({
components: { MainView, App },
setup() {
router.replace('/main')
},
template: '<app><main-view /></app>'
}),
parameters: {
layout: 'fullscreen'
}
}
export const Default: Story = {}
- e2e/vue.spec.ts
- public/favicon.ico (replace with own)
- styles (create SMACSS structure if necessary)
- src/assets/styles/base.css
- src/assets/styles/main.css
- src/assets/logo.svg
- src/components/__tests__ (tests are in the components/views folders)
- src/components/icons
- src/components/HelloWorld.vue
- Home page components after updating the home page
- src/components/TheWelcome.vue
- src/components/WelcomeItem.vue
- src/views/AboutView.vue
- src/stories
- .mocks*
- api
- graphql*
- handlers.ts
- .storybook*
- main.ts
- preview-head.html
- preview.ts
- e2e*
- testName
- testName.spec.ts
- testName.stories.ts
- testName
- public
- favicon.ico
- mockServiceWorker.js*
- src
- assets
- styles
- _transitions.scss*
- styles.scss
- tailwind.css*
- images
- styles
- components
- ComponentName
- ComponentName.spec.ts*
- ComponentName.stories.ts*
- ComponentName.vue
- ComponentName
- constants*
- constantName.constants.ts*
- locales*
- language.json
- models
- type.types.ts
- plugins
- i18n.ts*
- index.ts
- pinia.ts*
- vuequery.ts*
- vuetify.ts*
- webfontloader.ts*
- router
- index.ts
- services*
- serviceName.service.ts
- stores*
- storeName.ts
- views
- ViewNameView
- ViewNameView.stories.ts*
- ViewNameView.vue
- ViewNameView
- App.vue
- main.ts
- assets
- .env
- .env.example
- .env.msw*
- .eslintrc.cjs
- .gitattributes
- .gitignore
- .prettierrc.json
- env.d.ts
- index.html
- package-lock.json
- playwright.config.ts*
- README.md
- tsconfig.app.json
- tsconfig.config.json
- tsconfig.json
- tsconfig.vitest.json*
- vite.config.ts
- yarn.lock