BLOG

ブログ

【Nuxt3】Vuetify3 + Piniaで通知トーストの表示・単体テスト実装について

こちらは、Mavs Advent Calendar2023の2日目の記事です!🎅

ハコザキです。
今回は、Nuxt3 + Vuetify + Vitest + Piniaの環境を構築した後、
Vuetifyのコンポーネントを用いて以下のようなトースト表示をできるようにしたいと思います。

また、Piniaのテストについても少し紹介したいと思います。

Nuxt3 + Vuetify + Vitestの構築

Nuxt3 + Vuetify + Vitestにつきましては、こちらの記事で紹介しております。
【Nuxt3】Vuetify3 + Vitestで単体テスト実行環境を構築
今回はこの環境を使用した上で進みたいと思います!

Piniaの導入

続いて、上記環境にPiniaの導入を行っていきます。
基本的に公式ドキュメントのNuxt対応方法を参考に進めてます。
Nuxt.js | Pinia

ライブラリのインストールを行います。

pnpm i pinia @pinia/nuxt

nuxt.config.tsのmodulesに追加します。

import path from 'path'
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  devtools: { enabled: true },

  srcDir: './src/',

  components: [
    {
      path: path.resolve(__dirname, './src/components/'),
      pathPrefix: false,
    },
  ],

  build: {
    transpile: ['vuetify'],
  },

  modules: [
    (_options, nuxt) => {
      nuxt.hooks.hook('vite:extendConfig', (config) => {
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-expect-error
        config.plugins.push(vuetify({ autoImport: true }))
      })
    },
    '@pinia/nuxt',
  ],

  vite: {
    vue: {
      template: {
        transformAssetUrls,
      },
    },
  },
})

これでPiniaの導入は完了しました。
PiniaはNuxt3のAuto importも対応していますが、今回は割愛します。
Nuxt.js Auto Imports | Pinia

通知トーストの実装

VuetifyにはSnackbarというコンポーネントが用意されているため、
このコンポーネントを用いてトーストを表示したいと思います。

トーストは一時的なメッセージの表示に使われるもので、
ログインや何かしらの登録/削除のイベントなど、様々な場面で使用されます。
そのためトースト自体をコンポーネント化し、
表示やテキストを状態管理で保持しておき、どこからでも呼び出せるように実装したいと思います。

一つのコンポーネントで通知トーストの表示

まずはボタンを押してトーストが表示されるコンポーネントを作成します。 ~/src/components/Toast/TheToast.vueを作成し以下を記述します。

<script setup lang="ts">
const isActive = ref(false)
</script>

<template>
  <VBtn color="success" width="100px" @click="isActive = true">通知を表示</VBtn>
  <VSnackbar
    v-model="isActive"
    multi-line
    timeout="1500"
    color="success"
    location="top center"
    variant="tonal"
    class="py-2"
    @on-vnode-unmounted="isActive = false"
  >
    通知
  </VSnackbar>
</template>

~/src/layouts/default.vueで通知トースト用のコンポーネントを呼び出し、
トーストを表示します。

<template>
  <VApp>
    <TheToast />
  </VApp>
</template>

ボタンをクリックすると上部中央にトーストが表示される

トーストをどこでも呼び出せるように改修

続いて、このトーストをどこでも呼び出せるようにします。
現在のTheToastコンポーネントはボタンと表示するトーストがセットになっています。
どこでも呼び出せるようにするコンポーネントには不要のため、ボタンは削除しておきます。

~/src/components/Toast/TheToast.vue を以下のようにします。

<script setup lang="ts">
const isActive = ref(false)
</script>

<template>
  <VSnackbar
    v-model="isActive"
    multi-line
    timeout="1500"
    color="success"
    location="top center"
    variant="tonal"
    class="py-2"
    @on-vnode-unmounted="isActive = false"
  >
    通知
  </VSnackbar>
</template>

続いて、トーストの表示・非表示の状態を全体で管理できるように、
Piniaでの状態管理を行います。

Piniaでのトースト表示の状態を管理

~/src/store/toast.tsを作成します。

import { defineStore } from 'pinia'

interface State {
  isActive: boolean
}

export const useToastStore = defineStore('toast', {
  state: (): State => ({
    isActive: false,
  }),
  actions: {
    unsetSnackbar() {
      this.$reset()
    },
    setActive() {
      this.isActive = true
    },
  },
})

このストアでは、後ほど色々追加しますが、
ひとまず表示/非表示のBooleanを定義しておきます。
unsetSnackbar()では$reset()を活用してState状態のリセット、
setActive()を呼び出すことで表示にするというアクションを定義しました。

続いて、TheToastコンポーネントの表示用のv-modelをStateの値と連携します。

<script setup lang="ts">
import { useToastStore } from '~/store/toast'

const toastStore = useToastStore()
</script>

<template>
  <VSnackbar
    v-model="toastStore.isActive"
    multi-line
    timeout="1500"
    color="success"
    location="top center"
    variant="tonal"
    class="py-2"
    @on-vnode-unmounted="toastStore.unsetSnackbar()"
  >
    通知
  </VSnackbar>
</template>

v-model="toastStore.isActive" で表示状態を同期します。
@on-vnode-unmounted="toastStore.unsetSnackbar()" でトーストがタイムアウトになったとき、 unsetSnackbar()を呼び出し、State状態のリセットをします。

トースト呼び出し側の実装

呼び出し側はトーストのState状態を表示にするだけでokです。

~/src/layouts/default.vue を以下のようにします。

<template>
  <VApp>
    <slot />
    <TheToast />
  </VApp>
</template>

続いて、~/src/pages/index.vue でボタンを設置し、トーストを表示させてみます。

<script setup lang="ts">
import { useToastStore } from '~/store/toast'

const toastStore = useToastStore()
</script>

<template>
  <VContainer>
    <VBtn color="success" @click="toastStore.setActive()"
      >通知を表示(トップページ)</VBtn
    >
  </VContainer>
</template>

動作確認

ボタンクリック → トーストの表示の流れは同じですが、
pagesで定義したボタンをクリックすることでトースト表示できていればokです!

通知タイトルやColorのカスタマイズ

一通りのトースト表示の実装はできました。
このままでは毎回 通知 というトーストが表示されるだけで汎用性がありません..
タイトルやトーストの色をカスタマイズしたいと思います!

まずはストアを編集します。
~/src/store/toast.ts を以下のようにします。

import { defineStore } from 'pinia'

interface State {
  isActive: boolean
  text: string | null
  color: 'success' | 'error'
}

export const useToastStore = defineStore('toast', {
  state: (): State => ({
    isActive: false,
    text: null,
    color: 'success',
  }),
  actions: {
    /**
     * Stateの初期化
     */
    unsetSnackbar() {
      this.$reset()
    },
    /**
     * トーストの表示
     */
    setToast(text: State['text']) {
      this.isActive = true
      this.text = text
    },
    setSuccessToast(text: State['text']) {
      this.setToast(text)
      this.color = 'success'
    },
    setErrorToast(text: State['text']) {
      this.setToast(text)
      this.color = 'error'
    },
  },
})

TheToastコンポーネントでは表示テキスト・Colorを直接指定していました。
Stateの値を利用して動的にします。
~/src/components/Toast/TheToast.vue を以下のようにします。

<script setup lang="ts">
import { useToastStore } from '~/store/toast'

const toastStore = useToastStore()
</script>

<template>
  <VSnackbar
    v-model="toastStore.isActive"
    multi-line
    timeout="1500"
    :color="toastStore.color"
    location="top center"
    variant="tonal"
    class="py-2"
    @on-vnode-unmounted="toastStore.unsetSnackbar()"
  >
    {{ toastStore.text }}
  </VSnackbar>
</template>

続いて、呼び出し側に2つのボタンを設置します。
~/src/pages/index.vueを以下のようにします。

<script setup lang="ts">
import { useToastStore } from '~/store/toast'

const toastStore = useToastStore()
</script>

<template>
  <VContainer>
    <VBtn color="success" @click="toastStore.setSuccessToast('成功しました!')">
      成功トーストの表示</VBtn
    >
    <VBtn color="error" @click="toastStore.setErrorToast('失敗しました!')">
      失敗トーストの表示</VBtn
    >
  </VContainer>
</template>

この状態で動作確認します。
以下のようにそれぞれボタンクリックし、
トーストの表示が動的になっていればokです!!

ここまでで実装は完了しました!!
最後にPiniaのテストコードを書いてみたいと思います!!

おまけ:Piniaのテストについて

Piniaのテストについては、公式ドキュメントにも記載があります。
Testing Stores | Pinia

上記の参考コードをもとに、今回作成したトースト用のストア、
コンポーネントに対しテストコードを実装したいと思います。

トースト用ストアのテスト

~/src/tests/store/toast.spec.tsを作成し以下のようにします。

import { useToastStore } from '@/store/toast'
import { createPinia, setActivePinia } from 'pinia'

const toastText = 'トーストのテキスト'

describe('Toast Store', () => {
  beforeEach(() => {
    // 毎テストごとにStore初期化
    setActivePinia(createPinia())
  })

  it('初期値', () => {
    const toastStore = useToastStore()
    expect(toastStore.$state).toEqual({
      isActive: false,
      text: null,
      color: 'success',
    })
  })

  it('SuccessToastの表示', () => {
    const toastStore = useToastStore()
    toastStore.setSuccessToast(toastText)
    expect(toastStore.$state).toEqual({
      isActive: true,
      text: toastText,
      color: 'success',
    })
  })

  it('ErrorToastの表示', () => {
    const toastStore = useToastStore()
    toastStore.setErrorToast(toastText)
    expect(toastStore.$state).toEqual({
      isActive: true,
      text: toastText,
      color: 'error',
    })
  })
})

beforeEachでテストごとに初期化しています。

おわり

今回はVuetifyのSnackbarコンポーネントを活用し、
Nuxt3でどこからでも呼び出すことができるトーストの実装・Piniaのテストコードについて紹介しました。
トースト表示はログイン/ログアウトや、
何らかのイベントの結果に応じて表示されることが多いです。
今回Nuxt3 + Vuetifyで実装する機会があったため、実装例を紹介しました!

今回実装したコードは、以下のリポジトリにpushしております!
https://github.com/p-t-a-p-1/nuxt3-vuetify-toast

RELATED ARTICLE