UP | HOME
Diego Rodriguez Mancini

Diego Rodriguez Mancini

Software Engineer

Implementing a Clean Architecture Modular Application in Nuxt/Vue Typescript Part 4: UI Components
Published on Feb 28, 2021 by Diego Rodriguez Mancini.

Introduction

This is the last post on this series of creating a nuxt clean architecture demo application. In the previous part, we implemented the use case interactor and the Vuex store. This time we are going to create a home page view and a component and inject the vuex module to this home page.

Stuff we will se in this post:

For the complete example visit the code at this repository and also the other parts of the series: Part 1, Part 2 and Part 3.

The Home Page

Since this is just an example application, I’m going to build a very simple UI. The Home page will have a list of TaskCard components. The Home page will be fetching tasks at mount and passing the task data to the TaskCard component as props.

component-class-diagram.png

Figure 1: Component and store class diagram

If we could represent Vue components in UML it would look something like the above diagram. The HomePage uses the storage to access TaskStore and passes task data as a prop to the TaskCard component. It is a very simple setup, but if the app gets more complex, we could have many components using the TaskStore for example. If that is the case, we could inject the Storage to every component using Inversify-Props, or we could use provide and inject from Vue, inversify-props injecting the Storage to the HomePage, and the HomePage providing the store to required components.

Tests first

Testing Vue components, and in particular, Vuetify components we need to take some considerations: We need to use a local vue instance provided by Vue-Test-Utils and a Vuetify instance that we are going to pass to the mount function.

import { createLocalVue, mount } from "@vue/test-utils"
import { cid, container, mockTransient, resetContainer } from "inversify-props"
import Vuex from 'vuex'
import Vue from 'vue'
import Vuetify from 'vuetify'
import { containerBuilder } from "../../plugins/inversify"
//@ts-ignore
import HomePage from '@ui/pages/index.vue'
import { ITaskRepository } from "@@/src/app/modules/task/domain"
import { MockTaskService } from "@@/src/app/modules/task/infrastructure/__tests__/TaskService.mock"
import { TestActions } from "@@/src/app/shared/http/HttpService.mock"
import { IStorage } from "@@/src/app/shared/storage/Storage"
import { mockTaskData } from "@@/src/app/modules/task/domain/__tests__/Task.mock"
import flushPromises from 'flush-promises'


describe('HomePage', () => {
    const localVue = createLocalVue()
    localVue.use(Vuex)
    Vue.use(Vuetify)
    let vuetify: Vuetify

    beforeEach(() => {
        resetContainer()
        containerBuilder()
        mockTransient<ITaskRepository>(cid.TaskService, MockTaskService)
        vuetify = new Vuetify()
    })

    it('mounts and renders tasks', async () => {
        container.bind<TestActions>('ActionType').toConstantValue('ok')
        const wrapper = mount(HomePage, {
            localVue,
            vuetify,
        })
        await flushPromises()
        const titles = wrapper.findAll('.v-card__title')
        expect(titles.length).toBe(mockTaskData().length)
        const storage = container.get<IStorage>(cid.Storage)
        const store = storage.getStores().taskStore
        expect(store.taskList).toStrictEqual(mockTaskData())
    })

One important thing here is the flushPromises function. It comes from the flush-promises package and it is used to wait for all promises to finish before executing the test assertions. Vue does a lot of things asynchronously, for example in this test case, mounting the component and fetching data from the mock API take several promises to finish. Using nextTick here didn’t work, so as suggested by the Vuetify docs, I decided to go with flush-promises.

The rest of the test is similar to all the tests we have done using inversify-props container in previous posts. One particular assertion, checks that the number of cards is equal to the number of tasks, so we know that the component is rendering the correct data. The next assertion checks that the store has the right data too, although, this is not really needed since the rendered data is coming from the store, if the rendered data is fine, then the store should be fine too.

We can now add test cases for errors. For this we are expecting that errors will be shown in a v-alert component.

it('mounts and renders server error', async () => {
    container.bind<TestActions>('ActionType').toConstantValue('serverError')
    const wrapper = mount(HomePage, {
        localVue,
        vuetify,
    })
    await flushPromises()
    expect(wrapper.find('.v-alert__content').text()).toContain('Server Error')
    const storage = container.get<IStorage>(cid.Storage)
    const store = storage.getStores().taskStore
    expect(store.taskList).toStrictEqual([])
    expect(store.error).toBeInstanceOf(HttpError)
})
it('mounts and renders client error', async () => {
    container.bind<TestActions>('ActionType').toConstantValue('clientError')
    const wrapper = mount(HomePage, {
        localVue,
        vuetify,
    })
    await flushPromises()
    expect(wrapper.find('.v-alert__content').text()).toContain('Client Error')
    const storage = container.get<IStorage>(cid.Storage)
    const store = storage.getStores().taskStore
    expect(store.taskList).toStrictEqual([])
    expect(store.error).toBeInstanceOf(HttpError)
})
it('mounts and renders parse error', async () => {
    container.bind<TestActions>('ActionType').toConstantValue('parseError')
    const wrapper = mount(HomePage, {
        localVue,
        vuetify,
    })
    await flushPromises()
    expect(wrapper.find('.v-alert__content').text()).toContain('Parse Error')
    const storage = container.get<IStorage>(cid.Storage)
    const store = storage.getStores().taskStore
    expect(store.taskList).toStrictEqual([])
    expect(store.error).toBeInstanceOf(ParseError)
})

The HomePage code

As described before, we are injecting the Storage and getting the TaskStore instance from it to fetch tasks at mount. With Nuxt-Property-Decorator, getters are computed properties, so we are using 4 getters to obtain data from the store, one for tasks, for serverError, clientError and parseError.

<template>
  <v-row justify="center" align="center">
    <v-col v-if="serverError">
      <v-alert>Server Error: {{ serverError }}</v-alert>
    </v-col>
    <v-col v-if="clientError">
      <v-alert>Client Error: {{ clientError }}</v-alert>
    </v-col>
    <v-col v-if="parseError">
      <v-alert>Parse Error: {{ parseError }}</v-alert>
    </v-col>
    <v-col v-for="task in tasks" :key="task.title" cols="12" sm="8" md="6">
      <task-card :task="task" />
    </v-col>
  </v-row>
</template>

<script lang="ts">
import { inject } from 'inversify-props'
import { Vue, Component } from 'nuxt-property-decorator'
import { Storage } from '@@/src/app/shared/storage/Storage'
import TaskCard from '@@/src/app/modules/task/ui'
import { isHttpError } from '@@/src/app/shared/http'
import { ParseError } from '@@/src/app/shared/error/ParseError'
@Component({
  components: { TaskCard },
})
export default class Home extends Vue {
  @inject() private storage!: Storage

  async mounted() {
    await this.storage.getStores().taskStore.fetchTasks()
  }

  get tasks() {
    return this.storage.getStores().taskStore.taskList
  }

  get serverError() {
    const error = this.storage.getStores().taskStore.error
    if (!error) return null
    if (isHttpError(error)) {
      if (error.isServerError()) return error.message
    }
    return null
  }

  get clientError() {
    const error = this.storage.getStores().taskStore.error
    if (!error) return null
    if (isHttpError(error)) {
      if (error.isClientError()) return error.message
    }
    return null
  }

  get parseError() {
    const error = this.storage.getStores().taskStore.error
    if (!error) return null
    if (isHttpError(error)) return null
    if (error instanceof ParseError) {
      return error.message
    }
    return null
  }
}
</script>

For the template we are using conditional rendering to change between error and task data. The tasks are shown in TaskCard components that are created in the v-for sentence.

The TaskCard component

This is a very simple component. It only gets task data as props and displays the title and description of the task. Feel free to add buttons to change state and more functionalities.

<template>
  <v-card>
    <v-card-title>
      {{ task.title }}
    </v-card-title>
    <v-card-text>
      {{ task.description }}
    </v-card-text>
  </v-card>
</template>

<script lang="ts">
import { Vue, Component } from 'nuxt-property-decorator'

const TaskProps = Vue.extend({
  props: {
    task: Object,
  },
})

@Component
export default class TaskCard extends TaskProps {}
</script>

Back to the HomePage tests

Now the tests are passing. If we run all tests we get the following output.

PASS src/app/modules/task/domain/__tests__/Task.spec.ts
PASS src/app/modules/task/infrastructure/__tests__/TaskService.spec.ts
PASS src/app/modules/task/usecase/__tests__/GetMany.spec.ts
PASS src/app/modules/task/storage/__tests__/TaskStore.spec.ts
PASS src/ui/pages/__tests__/Home.spec.ts
---------------------------------|---------|----------|---------|---------|-------------------
File                             | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
---------------------------------|---------|----------|---------|---------|-------------------
All files                        |   93.44 |       82 |      86 |   93.16 |
 app/modules/task/domain         |   93.33 |     87.5 |     100 |   93.33 |
  Task.ts                        |   92.31 |     87.5 |     100 |   92.31 | 23
  index.ts                       |     100 |      100 |     100 |     100 |
 app/modules/task/infrastructure |   72.73 |     37.5 |   44.44 |   70.97 |
  TaskParser.ts                  |   61.54 |     37.5 |      50 |   61.54 | 18-32
  TaskService.ts                 |   76.47 |      100 |   42.86 |   73.33 | 19-28
  index.ts                       |     100 |      100 |     100 |     100 |
 app/modules/task/storage        |     100 |      100 |     100 |     100 |
  TaskStore.ts                   |     100 |      100 |     100 |     100 |
  index.ts                       |     100 |      100 |     100 |     100 |
 app/modules/task/ui             |     100 |      100 |     100 |     100 |
  index.ts                       |     100 |      100 |     100 |     100 |
 app/modules/task/usecase        |     100 |      100 |     100 |     100 |
  GetMany.ts                     |     100 |      100 |     100 |     100 |
 app/shared/error                |     100 |      100 |     100 |     100 |
  ParseError.ts                  |     100 |      100 |     100 |     100 |
 app/shared/http                 |   95.37 |       90 |    87.5 |   95.24 |
  HttpError.ts                   |   92.31 |      100 |      80 |   91.67 | 32
  HttpService.ts                 |   86.67 |       50 |      90 |   85.71 | 37,78-79,103
  HttpStatusCode.ts              |     100 |      100 |     100 |     100 |
  index.ts                       |     100 |      100 |     100 |     100 |
 app/shared/storage              |     100 |       50 |     100 |     100 |
  Storage.ts                     |     100 |       50 |     100 |     100 | 16
 ui/pages                        |   96.15 |    94.44 |     100 |      96 |
  index.vue                      |   96.15 |    94.44 |     100 |      96 | 64
---------------------------------|---------|----------|---------|---------|-------------------

Test Suites: 5 passed, 5 total
Tests:       19 passed, 19 total
Snapshots:   0 total
Time:        4.393 s
Ran all test suites.

Conclusion

We finished a demo application with a simple but complete use case. In this last part we implemented and tested Vue components created with Nuxt-Property-Decorator and Vuetify.

I hope this was helpful to you, and remember, this should not be treated as a guide that you can follow to build any application, treat it more like an explained example.

If you want to keep playing with the code or add more functionalities, the repository is available at https://gitlab.com/dirodriguezm/nuxt-clean-architecture. For questions, commentaries or corrections you can contact me at diegorodriguezmancini@gmail.com