This Readme contains summaries of the chapters
In this section of part 1, we will learn how to:
- hook the Vue.js library into our page
- initialize and plug a Vue application into our page and
- demonstrate the rendering of values
The Vue.js script can be loaded from CDN
<script src="https://unpk.com/vue@3"></script>
An instance of the app must be created.
<script type="text/javascript">
const = app = Vue.createApp({})
</script>
The instance of the app can then be mounted to an entrypoint
.
The entrypoint is defined with an id
-attribute for an html container tag such as a div-tag
...
<body>
<div id="app-entrypoint">
</div>
</body>
...
If the entrypoint is defined, it can be passed on the instantiated app.
This done using the mount
method of the instance which expects the id-tag of the entrypoint as an argument
...
<script type="text/javascript">
const = app = Vue.createApp({})
const mountedApp = app.mount('#app-entrypoint')
</script>
...
By defining a method called data
it is possible to use the contents of the returned object to render into the page
const app = Vue.createApp(
{
data () {
return {
property1: 'value',
property2: 'otherValue'
}
}
}
)
Rendering the values is done by placing the property name at the desired rendering place within double curley braces. Of course, the rendering location must be within or within a child tag of the tag which is our entrypoint.s
<div id="#app-entrypoint">
<div>{{ property1 }}
...
Plugins for major browsers are available. They might help during debugging
The v- for directive is the way to create for-loops in Vue. This can e.g. be used to populate an unordered list with a dynamic amount of list elements.
<ul>
<li v-for="user in users" v-bind:key="user">{{ user }}</li>
</ul>
It assumes that, we have a users
property in the data
property/object of the Vue app and
that it is an iterable (e.g. list of strings).
We can additionally unpack the index
<ul>
<li v-for="(user, index) in users" v-bind:key="user">{{ index }}: {{ user }}</li>
</ul>
v-on
is a directive which will attach listener to the element to which the directive is added as an attribute.
<button v-on:click="displayUsers = !displayUsers">Display Users!</button>
This assumes that a displayUsers
data-property exists.
Notice that we can
- change the value of the property and
- that we have access to the value of properties and
- apply logic to it.
Notice that the event here is a click
on the element.
The shorthand version for v-on
is @
.
<button @click="displayUsers = !displayUsers">Display Users!</button>
v-show
can be used to change the visibility of elements.
The following examples visibility is now conditional of the truth value of the displayUsers
data property
<ul v-show="displayUsers">
</ul>
The v-if
directive has a similar effect as the v-show
directive.
The difference is that v-show
regulates the display: none
style attribute
whereas the v-if
creates / destroys the sub-elements.
v-show
should be used for simple objects which are expected
to be toggled often.
v-if
should be used for elements which are more complex, are rarely toggled bc
the state of the sub-elements will be more clear.
<main>
<button v-on:click="displayUsers = !displayUsers">Display Users!</button>
<h1>List of Users:</h1>
<ul v-if="displayUsers">
<li v-for="(user, index) in users" v-bind:key="user">{{ index }}: {{ user }}</li>
</ul>
</main>
The tutorial advises to use the v-else
directive after places where a v-if
directive is used.
Both need to be on the same element hierarchy.
<!-- valid -->
<div>
<ul v-if="condition"></ul>
<ul v-else></ul>
</div>
<!-- invalid bc if and else clause are on different levels -->
<div>
<ul v-if="condition"></ul>
<div>
<ul v-else></ul>
</div>
</div>
it is advised to leave logic in the JS instead of the html.
As an example the toggling of the variable displayUsers
data property is currently done
within the HTML:
<button v-on:click="displayUsers = !displayUsers">Display Users!</button>
Instead, we can write a reusable method and link it up in the HTML instead.
const = app = Vue.createApp({
data () {
return {
property: 'value'
}
}
});
The concept of data binding connects data in a Vue app with the attributes of an HTML element. For example, we can access values of a form or field and process the data with methods.
<div class="add-user-input">
<input type="text" v-model="newUser" placeholder="Enter new user..."></input>
<input type="submit" value="Add User" v-on:click="addNewUser(newUser)"></input>
</div>
...
<script>
const app = Vue.createApp({
data() {
return {
//...
newUser: '',
users: [
]
}
},
methods: {
//...
addNewUser(newUserInput) {
if (newUserInput !== '') {
this.users.push(newUserInput)
this.newUser = ''
}
}
}
</script>
v-model
creates a two-way data binding. If the user changes the value in the input field, the value in the data property
of the Vue instance will change. Likewise, the displayed string in the input field will change if the value of the data
property is changed.
v-on:click
of the submit input field is used to capture the "send" event and process it with the method addNewUser
.
The current value of newUser
is used as an argument.
The computed
property of the Vue application holds methods which
can return values based on logic. They are comparable to a property on a Django model.
The advantage of a computed property is that we can take values of many data points into account.
{{ renderingOfComputedValue }}
<script>
const = Vue.createApp({
data() {
// ...
},
methods: {},
computed: {
renderingOfComputedValue () {
return this.someValue + this.otherValue
}
}
})
</script>
A search feature can be introduced as a more complex example for computed properties. For that it is needed to collect the user input for the search term:
<div class="search-user-input">
<label for="searchInput">Search for:</label>
<input type="text" v-model="searchName" id="searchInput"></input>
</div>
The input field has a data binding with the searchName
data property.
Somehow, we would like to go through the list of users and filter out usernames
which match our criteria.
A method can be preferred if there is a distinct event triggering a calculation. In the case of search it now common to get the results as we type. Therefore, a computed property makes more sense.
const app = Vue.createApp({
// ...
computed: {
// ...
searchResults() {
if (this.searchName !== '') {
return this.users.filter(el => el.match(this.searchName));
} else {
return '';
}
}
}
// ...
});
The filter
method of the array expects a function from us.
This is done on the go using an arrow style function.
Each element of the array will be evaluated against that function.
In our case, the match
string method is used.
Finally, after 1) collecting the search term and 2) applying a filtering logic we can 3) render the results:
<ul>
<li v-for="user in searchResults" v-bind:key="user">{{ user }}</li>
</ul>
Note that we use the computed property in a for loop. The code woks although our searchResults returns two different types. In the case of at least one search result, it returns an array of stings. In the case of no result, it will return just an empty string
HTML elements can be styled dynamically using Vue.
One way would be to modify the style inline by using setting a value to the style
attribute.
Another way would be to predefine classes in our CSS file and switch the target tags class to our need.
<li v-for="(user, index) in users" v-bind:style="{color: randomColor}" v-bind:key="user">{{ user }}</li>
Which would ask for a computed property randomColor
, which returns valid css color values.
We bind the target elements class attribute to Vue using v-bind:class
and set it to a
computed method named someFancyComputedValue
<div v-bind:class="someFancyComputedValue">Lorem Ipsum</div>
Our JS code would than look like:
// ...
computed: {
// ...
someFancyComputedValue() {
if (this.condition) {
return 'regularDivStyle';
} else {
return 'warningDivStyle';
}
}
// ...
}
Of course that assumes that these selectors are defined in our CSS files:
div regularDivStyle {
color: black;
}
div warningDivStyle {
color: orange;
}
The second part is going to be about components, Vue build tools and testing.
As a preparation for the build tools, we are going to separate our JS code
from our HTML file into a file called app.vue
.
Components are parts of an application. The syntax for defining a component is:
const app = Vue.createApp({});
app.component(
'app-specific-name-for-component',
{
'template': '<div>Minimalist static template</div>'
}
)
The component
factory method takes two arguments.
The first argument is the component name. In this case it's 'app-specific-name-for-component'
.
The defined component can now be used in the area where our Vue app is mounted:
<div id="vue-app">
<app-specific-name-for-component></app-specific-name-for-component>
</div>
Notice the that it's advised to use app
as an additional namespace
to avoid collisions with builtin HTML tags (e.g. in the case of header
).
The setup is going to be change to work with node and npm. For that I followed the instructions from https://github.com/nodesource/distributions.
While the tutorial works with:
$ node -v
v16.16.0
$ npm -v
8.13.2
I have:
$ node -v
v18.14.1
$ npm -v
9.3.1
What could possibly go wrong?
The journey is continued with
npm init vue@latest
Today I got [email protected]
vs [email protected]
described in the tut.
Summary of init options: ✔ Project name: … vue-crud-manual-auto ✔ Add TypeScript? … No ✔ Add JSX Support? … No ✔ Add Vue Router for Single Page Application development? … No ✔ Add Pinia for state management? … No ✔ Add Vitest for Unit Testing? … Yes ✔ Add an End-to-End Testing Solution? › No ✔ Add ESLint for code quality? … Yes ✔ Add Prettier for code formatting? … No
After the right config for the folder was set in place we run:
npm install
The developement server in this version can be started with:
npm run dev
The structure of a node supported app is:
.
├── index.html
├── src
│ ├── App.vue
│ ├── assets
│ │ ├── base.css
│ │ └── main.css
│ ├── components
│ │ └── AppHeader.vue
│ └── main.js
The most basic Structure here is the component AppHeader.vue
.
The components are going to be imported into App.vue
and assembled to an app.
The assembled app is going to be imported into main.js
where the mounting will take place.
main.js
is going to be required from index.html
.
The basic structure of a component file is:
<script setup>
</script>
<template>
</template>
<style scoped>
</style>
In this case the CSS defined here will only be applied to elements in that specific
file. The scoped
keyword is responsible for that.
The most basic CSS (e.g. resetting or normalize) is paced inside base.css
.
base.css
is imported into main.css
with:
@import "./base.css";
Finally, base.css
is imported into main.js
with:
import './assets/main.css'
In the tutorial unit testing is done with vitest
.
Tests are stored in the components
folder within a subdirectory called __tests__
.
The naming convention for tests is <component name>.spec.js
.
├── src
│ ├── App.vue
│ ├── assets
│ │ ├── base.css
│ │ └── main.css
│ ├── components
│ │ └── AppHeader.vue
│ │ └──__tests__
│ │ │ ├── AppHeader.spec.js
.. .. .. └── ..
The test files include import statements and the actual test code.
The following example in which the AppHeader.vue
component is tested.
Full test file
import { describe, it, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import AppHeader from '@/components/AppHeader.vue'
describe('AppHeader.vue Test', () => {
it('renders message when component is created', () => {
const wrapper = shallowMount(AppHeader)
expect(wrapper.text()).toMatch('Vue Project')
const items = wrapper.findAll('li')
expect(items[2].text()).toMatch('Contact')
})
})
First, testing utilities are imported. They include
describe
which is used to mark a test suit (i.e. a group of tests),it
which is used to define a single unit test.it
is an alias for `test.expect
which is used to make assertions.
import { describe, it, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import AppHeader from '@/components/AppHeader.vue'
The suit of unit tests set up with describe
. describe
takes
a description and the test functions as an argument.
describe('AppHeader.vue Test', () => {
// individual tests
})
describe
: https://vitest.dev/api/#describe
An individual test is written using test
or its alies it
it('renders message when component is created', () => {
// arrange act assert
})
test
:https://vitest.dev/api/#test-api-reference
To make assertions on the component we are testing, we will need to run that component. That is done with two utilities:
shallowMount
will mock out subcomponents. This helps isolated testing but requires
another step of testing which checks the interaction of child nd parent component.
const wrapper = shallowMount(AppHeader)
For that purpose, mount
can be used which loads all child components.
const wrapper = mount(AppHeader)
wrapper object returned by mount
and shallowMount
: https://test-utils.vuejs.org/api/#wrapper-methods
Finally, the assertions can be done on the now live app/component:
expect(wrapper.text()).toMatch('Vue Project')
const items = wrapper.findAll('li')
expect(items[2].text()).toMatch('Contact')
expect
: https://vitest.dev/api/expect.html
Tests can be run with:
npm run test:unit
Missing components were suggested to be installed when I ran the command the first time.
A suggested adjustment of the commands within package.json
is:
{
...
"scripts": {
...
"test:unit": "vitest run --environment jsdom",
"test:coverage": "vitest run --environment jsdom --coverage",
"test:ui": "vitest --environment jsdom --coverage --ui",
...
},
...
}
After this part of the tutorial, this is what my output looks like:
npm run test:coverage
> [email protected] test:coverage
> vitest run --environment jsdom --coverage
RUN v0.25.8 /home/kan/dev/js/vue-crud-manual
Coverage enabled with c8
✓ src/components/__tests__/AppFooter.spec.js (1)
✓ src/components/__tests__/AppContent.spec.js (1)
✓ src/components/__tests__/AppHeader.spec.js (1)
✓ src/components/__tests__/App.spec.js (1)
Test Files 4 passed (4)
Tests 4 passed (4)
Start at 13:46:40
Duration 3.00s (transform 328ms, setup 1ms, collect 820ms, tests 149ms)
% Coverage report from c8
-----------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
src | 100 | 100 | 100 | 100 |
App.vue | 100 | 100 | 100 | 100 |
src/components | 100 | 100 | 100 | 100 |
AppContent.vue | 100 | 100 | 100 | 100 |
AppFooter.vue | 100 | 100 | 100 | 100 |
AppHeader.vue | 100 | 100 | 100 | 100 |
-----------------|---------|----------|---------|---------|-------------------
Props describe properties which can span across the components' hierarchy. First, the title property need to marked to be dynamic:
// AppHeader.vue
<script setup>
const props = defineProps({
title: {type: String, required: true}
})
</script>
<template>
<h1>{{ title }}</h1>
</template>
// App.vue
<template>
<AppHeader title="Vue Project"></AppHeader>
</template>
passing prop values dynamically is recommended by the tutorial.
E.g. a variable named headerTitle
is defined and initialized using ref
.
// App.vue
<script>
...
const headerTitle = ref("Vue Project (Dynamic)")
</script>
And connected to the property called title
in the scope of the AppHeader component.
<AppHeader v-bind:title="headerTitle"></AppHeader>
Just like in the static case, there must be a prop definition in the script part of the subcomponent:
// AppHeader.vue
<script setup>
const props = defineProps({
title: {type: String, required: true}
})
</script>
The structure of the unit tests can be kept as is.
However, since the component now expects external data during initialisation,
we will need to provide that additional data during tests by passing argument
to the shallowMount
function:
const wrapper = shallowMount(
AppHeader,
{
propsData: {
title: 'Vue Project'
}
}
)
slot are a way to inherit HTML like elements. In contrast, props are better to pass data across component hierarchies.
In the subordinate component we define the location for a slot like this:
<template>
<div>
<slot name="message"></slot>
</div>
</template>
In the parent component, this slot can be used with another template
tag:
<template>
<template v-slot:message>Vue Project from TestDriven.io</template>
</template>
The naming can be omitted if there is just one slot.
A short version of v-slot:message
would be #:message
.
In this part, a table similar to the one in part 1 was created. This time however with components and test coverage.
We defined created a new component ListUsers
and defined the users data
to be passed from the parent via props
.
As learned earlier the defineProps function is used for that. It does not need to be imported.
<script setup>
const props = defineProps({
users: {type: Array, required: true}
})
<script>
The component was than be assembled in the AppConent
component.
<script>
import { ref } from '@vue/reactivity'
const users = ref([{
// data
}])
</script>
<template>
<Listusers v-bind:users="users">
</template>
The table was dynamically rendered in the ListUsers
component using the
v-for
directive learned in part 1. The unique key to bind HTML elements to
was picked to be the users.id
property:
// ...
<tr v-for="user in users" v-bind:key="user.id">
<td> {{ user.id }}</td>
// ...
Props were used to propagate data from a parent to a child component.
Custom events enable data to flow the other way around.
The AddNewUser
component was created to showcase that.
First, our data variables are defined and marked to be reactive:
const user = ref({
name: '',
username: '',
email: ''
})
Note that the properties of the user
variable (actually constant, but the values of the properties can be muted)
can be read or set with:
user.value.propertyName,
ref
reference. https://vuejs.org/api/reactivity-core.html#ref
Next the createUser
event to be emitted was declared:
const emit = defineEmits(['createUser'])
Finally, the event and the arguments to it (form/field data) are prepared and emitted:
const addNewUser = () => {
emit('createUser', {
name: user.value.name,
username: user.value.username,
email: user.value.email
})
// Clear the variables used for reading in the new user's info
user.value.name = ''
user.value.username = ''
user.value.email = ''
}
The emitting function is connected to the submit button.
In our case, v-on.click.prevent
was used to block an HTTP Post trigger.
<form>
//....
<div class="field">
<button v-on:click.prevent="addNewUser">Add User</button>
</div>
//...
</form>
The parent component AppContent
stores the user data.
We want to add a new user to that array of user objects.
First, the new component is imported to the component and instantiated in the template part:
<AddNewUser v-on:createUser="createNewUser"></AddNewUser>
An event listener is created for the createUser
event and connected to the
createNewUser
callback.
The callback could be defined like this:
const createNewUser = (user) => {
// Check that all fields are filled in before adding the user
if ((user.name !== '') && (user.username !== '') && (user.email !== '')) {
var newUser = {
id: users.value.length + 1,
name: user.name,
username: user.username,
email: user.email
}
// Add the user to the local array of users
users.value.push(newUser)
}
}
The testing repertoire was extended to setup
and teardown
methods.
In vitest
they are called beforeEach
and afterEach
.
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
In the case of the tests of AddNewUser
component the mounting of the component
was moved out of the test in favour of hosting it in setup method.
Interestingly, the wrapper
is a global variable and the setup and teardown method are
defined in the file itself.
It was finally executed what was just mentioned before: A test suit function can have multiple individual unit test functions.
Interestingly the async was taken care of in the latter test functions.
describe('AddNewUser.vue Test', () => {
it('initializes with correct elements', () => {
//...
})
it('emits an event when a new user with valid data is added', async () => {
//...
})
it('does not emit an event when a new user without data is added', async () => {
//...
})
})
It was introduced on how a value can be set to an element:
const nameInput = wrapper.find('#newName')
await nameInput.setValue('Name1')
// some action here
// magic
// ----
expect(nameInput.element.value).toBe('')
First, elements can be selected with their #id
using the find
method.
A new value can be assigned to it using the setValue
method.
Since, we want the changes to be reflected in the app and JS runs async
we want to await the setValue
method to be sure, that it is done before we make
actions or assertions based the value.
Finally, during assertions the current value of the element can be read using
the syntax variableNameOfElement.element.value
.
Interestingly, we can push buttons in our headless app.
It needs to be awaited for the same reason as we awaited the setValue
method.
await wrapper.find('button').trigger('click')
Last but not least, we asked for emitted event with:
const emittedEvent = wrapper.emitted('createUser')
We can also call it without a specific event name. Below is the output of
console.log(wrapper.emitted())
placed in test called emits an event when a new user with valid data is added
:
...
stdout | src/components/__tests__/AddNewUser.spec.js > AddNewUser.vue Test > emits an event when a new user with valid data is added
{
input: [ [ [Event] ], [ [Event] ], [ [Event] ] ],
change: [ [ [Event] ], [ [Event] ], [ [Event] ] ],
createUser: [ [ [Object] ] ],
click: [ [ [MouseEvent] ] ]
}
...
A reminder to have namespaced event names to avoid collisions with builtin or our own events.
A vue component or application has many stages from between the time the code arrives at the browser and the time the page is visibly rendered.
Reference https://vuejs.org/guide/essentials/lifecycle.html.
On each step we can place hooks which can selectivly execute code while our component passes through its lifecycle:
- setup
- beforeCreate
- created
- beforeMount
- mounted
- beforeUpdate
- updated
- beforeUnmount
- unmounted
This code is run first. in the composition API which was used in the single file component approach since part 2 this was called with the script tag itself:
<script setup>
</script>
Using the options API the setup method could be plugged in with.
<script>
const app = Vue.createApp({
setup() {
console.log("setup was called")
}
// ..
</script>
... is called
- after setup,
- before data and props are called
... is called after
- reactive data,
- computed properties,
- methods and
- watchers are available
... runs before the Vue instance is mounted into the Vue app or DOM element, meaning that the code is not yet rendered to DOM elements itself.
... is run after the component or Vue app has been mounted, implying that all templates and logic within it was rendered to DOM elements and the style has been applied to the elements.
... is run when reactive data which the component depends on changes. An update for the respective component is queued.
... is run after the change in reactive data has been reflected in the components appearance.
... is called when an unmount event is queued but not executed yet. Everything is still functionally intact at this point in time.
... is called after
- all child components have been unmounted
- computed properties are stopped
- watchers are unplugged
Could be used for clean up.
The code is placed into the part-3-lifecycle-hooks
branch.
The code in this chapter was developed beginning with the tests. True TDD.
axios is JS library to do HTTP requests.
The requests
package of JS so to speak.
npm install axios --save
The --save
flag adds the package to package.json
as the prod dependency.
During the unit tests however we do not want to hit the real API.
For this purpose axios-mock-adapter
is installed to the development requirements.
It has a similar purpose like mock and MagicMock for Python but is more targeted to
the axios library.
npm install axios-mock-adapter --save-dev
Axios Docs: https://axios-http.com/docs/intro Axios Mock Adapter Docs: https://github.com/ctimmerm/axios-mock-adapter#readme
It was shown that you can have several test suits in one file (i.e. describe
sections)
just as well as multiple individual unit tests (i.e. test()
or short it
)
The mocking library is instantiated on the file level using:
let mock = new MockAdapter(axios)
As such it is available for all test suits and unit test functions.
In contrast to an earlier test file the beforeEach
and afterEach
were defined within the describe
section.
As the setup for each testsuite is going to be different, and we want to use multiple testsuits
within the same file it makes sense to narrow down the scope of the setup and teardown methods.
Importantly regarding the mock we want to reset it:
afterEach(() => {
mock.reset();
/...
})
So it's cheaper to hit reset instead of getting a fresh new instance?
The target location for the HTTP request is going to be the onMounted
hook.
It's done after the first render to improve UX. To be ready for the hook being called
- the mock needs to be configured
- before the component is mounted using e.g.
shallowMount
The response on a get
method of the axios library can be defined per URL
with a specific status code integer, data object and header object
beforeEach( () => {
mock.onGet(url).reply(statusCode, data, header)
})
Just as well we can raise a timeout error:
//...
mock.onGet(url).timeout();
//...
The same pattern works for mocking other HTTP methods.
Responses to the HTTP POST method would be mocked with mock.onPost(...)
We can return any status code. As such we could test our code to handle 1s, 2s, 3s, 4s, 5s, or rogue ones.
//...
mock.onGet(url).reply(418);
//...
The HTTP request logic is packed into the onMounted
lifecycle hook.
The syntax of the axios library is:
axios.get(url)
.then((response) => {})
.catch((error) => {})
.finally()
Interestingly the async
keyword was necessary to make the code work.
This will recap many topics previously touched.
The data on which the banner is based is the result of the HTTP request to the remote API.
Since the call to the API takes place in AppContent
, it makes sense to define the data there.
Since it's going to be used in a child component the way to go is props
In the parent component the constants are marked reactive using ref
.
const messageToDisplay = ref('')
const messageType = ref('info')
In the child component the props are defined using defineProps
:
Implicitly, they are marked as not required.
const props = defineProps({
bannerMessage: String,
bannerType: String
})
Emits are a way to send data or a signal from the child to the parent component. In our case, the Banner component will host a button to clear the message with click. Signal will be emitted to the parent component which can take care of mounting it.
The emit is declared in the child component.
const emit = defineEmits(['clearBanner'])
It is then hooked to the HTML element via the callback which will formally emit the signal:
<script setup>
//...
const clearBannerMessage = () => {
emit('clearBanner')
}
</setup>
<template>
...
<span v-on:click="clearBannerMessage">Clear</span>
</template>
A listener is placed during the usage of the Banner
component in the parent:
<Banner v-on:clearBanner="clearMessage"></Banner>
If the clearBanner
event is emitted from the Banner
component, the clearMessage
callback
will be triggered:
const clearMessage = () => {
messageToDisplay.value = ''
messageType.value = 'Info'
}
The development was shortcut by one variable: the e.g. showBanner
variable of boolean type.
This was done by connecting the bannerMessage
called property in the Banner
component
with the div
element containing most of the banner.
<div v-show="bannerMessage">
<!-- -->
</div>
An emtpy string in JavaScript evaluates to
false
The background color of the Banner was designed to match the status of the HTTP request result. This means that it at least switches it value once from default to success or error.
A way to handle such a situation is a computed property. As the reactive data which is used in the method changes, the computed property is recomputed and so are elements depending on its value:
import { computed } from 'vue'
const bannerBackgroundColor = computed(() => {
if (props.bannerType === 'Error') {
return 'red'
} else if (props.bannerType === 'Success') {
return 'green'
} else {
return 'blue'
}
})
This computed property is connected to the banner via v-bind:style
directive set in the
containing div
element
<div v-bind:style="{ 'background-color': bannerBackgroundColor }">
</div>
The AppContent module contains the API query. It is extended by setting the appropriate banner message and type via side effects:
onMounted(async () => {
axios.get(url)
.then((response) => {
//...
messageType.value = 'Success'
messageToDisplay.value = '...'
//...
})
.catch((error) => {
//...
messageType.value = 'Error'
messageToDisplay.value = '...'
//...
})
})
TODO The strings for the banner message, color and type could be defined in module and exported within an object. That would reduce the possibility for typos since it DRYs up the strings. I'm especially thingking about the test methods.
Defining those strings in a module would also reduce maintenance burden and enable better visibility of the usage in the IDE.
flushPromises
was used to await the effects of the error handling promis.
The documentation on that says:
Why not just await button.trigger() ? As explained above, there is a difference between the time it takes for Vue to update its components, and the time it takes for a Promise, like the one from axios to resolve.
A nice rule to follow is to always await on mutations like trigger or setProps. If your code relies on something async, like calling axios, add an await to the flushPromises call as well.
Source: https://test-utils.vuejs.org/api/#flushpromises
The createNewUser
method in AppContent
was extended to include a POST call
using Axios. Afterwards, the users
prop was adjusted to the API call.
In reality, we would never include the ID upon creation but would leave that for the backend and adjust our JS data accordingly.
The API responds with the exact data which was sent to it. That's why we got out of the woods in this tutorial. We used our previously created banner to additionally display the status of the API call.
The tests were mostly similar to the previous two chapters. In this chapter, the post call for th API was additionally configured.
A button was added to each row of the html table.
The buttons were hooked up to trigger the deleteUserCallback
callback.
<button v-on:click="deleteUserCallback(user)">Delete</button>
Of course, that event needed to be declared fist in the setup part:
// ListUsers.vue
const emit = defineEmits(['deleteUser'])
The deleteUserCallback
then emitted the signal to the parent component
const deleteUserCallback = (user) => {
emit('deleteUser', user)
}
The signal was listened to on the parent component and triggered the callback with the same name as the event:
// AppContent.vue
<ListUsers v-on:deleteUser="deleteUser"}
The deleteUser
callback then did the API call using the axios library.
In this case, all data of the user was sent to endpoint.
However, in reality the endpoint, request data and response data would
depend on the used API.
In this chapter I utilized the template string of JS to compose the banner message. The URL could have been rendered in a similar way:
axios.delete(`https://something.com/endpoint/${user.id}`)
//...
As part of the success method, the users
array needed to be adjusted to
reflect the deletion of the user.
First we needed to identify the index of the specific user in our current array:
const indexOfUserToDelete = users.value.indexOf(userObject)
Then, we needed to remove the user from the array:
users.value.splice(indexOfUserToDelete, 1)
A JS basic which was used in this part:
> const list = [{id: 0}, {id: 1}, {id: 2}]
> const removedElement = list.splice(1, 1)
> list
[{id: 0}, {id: 2}]
> removedElement
{id: 1}
TODO Instead, I think I would prefer to do a GET request instead and just reload a clean version of the data.
The tests showed the extension on the mock to cover a successful and failing DELETE
request.
Some refactoring was done.
TODO I would go a step further and write a test utils module.
This was by far the most complex component of the course yet.
We want to be able to edit a user entry in the table.
- We want an edit button next to delete button. (It's not a good idea since to place edit right next to delete without space from UX perspective)
- When the edit button is click we want a modal to open.
- The modal should have fields to edit
name
,username
andemail
. - These fields should be initialized with the data of the user whose edit button was clicked. (i.e. user data in the same row as clicked edit button)
- The modal should have a submit and a cancel button.
- If the user clicks anywhere else outside the modal, it's interpreted as cancel.
- The cancel button should discard all changes.
- The submit button should
a) send a query to the backend to update the data
b) show the result of the update request to the backend
c) update the state of the user in the app accordingly
Array.findIndex(callable -> bool)
to get the index of the first object matching a filter functionObject.keys({a: 0, b: 1})
to get the names of the properties of an object- One attribute="value" per line style of coding to make code changes easier to figure out
- Inspect the outgoing request data in e.g. POST and PUT queries to match expectations.
expect(editButtons[1].isVisible()).toBe(true)
syntax to beexpect(wrapper.vm.showEditModal).not.toBeTruthy()
syntax to check that sth evaluates tofalse
- expect has very rich methods and compares to the django unit tests (with other names)
- events where reraised in an intermediate component (e.g. updateUser)
v-if
was used to clear the state of the modalv-on:click.stop=""
was used to block the registration of click events on the modal- dynamic assignment of HTML ID Tag (
v-bind:id="
deleteButton-${user.id}"
)
Pinia
is a state management
tool, which describes the centralized
management of globally (i.e. used application wide) used data.
Store
: describes the storage location of our dataState
: describes the global data in our store(s)Getters
: describes methods for read access to data within a storeActions
: describes methods for write access to data within a store
npm install pinia
And its testing tool
npm install @pinia/testing --save-dev
It's used as a plugin
in our application
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './assets/main.css'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')
We created a new dir in the src
dir called store
and created a new JS module.
In this case the banner.js name was chosen.
import { defineStore } from 'pinia'
export const useBannerStore = defineStore("banner", {
state: () => ({}),
getters: {},
actions: {}
})
This chapter showed how the props bannerMessage
and bannerType
were refactored to be stored and managed using Pinia.
The data is stored in properties of the store object.
Two properties with the same name as the props were created in the state
property
of the bannerStore Pinia object and initialized.
//...
state: () => ({
bannerMessage: '',
bannerType: 'Info'
})
//...
Similarly, the getters and actions objects were populated with methods.
In the case of the getters
property, the methods return the value of
the property in the state
object. The state
object is injected as an
argument to every method in the getters
object.
getters: {
getBannerMessage: (state) => { return state.bannerMessage }
//...
}
The actions
object methods are a bit different.
They take the new data as arguments.
After some modification/verification or other steps the existing data can
be overwritten using the this
keyword.
// ..
actions: {
setBannerData(message, type) {
this.bannerMessage = message
this.bannerType = type
}
// ..
}
Since the props are not supposed to be used anymore their inspection hast to be refactored towards checking the value of the properties in the respective store.
That required some changes to the test setup.
- the pinia mocking library and the store was imported
- the
mount
andshallowMount
calls were advanced by
- adding the fake pinia as plugin
- assigning a "spy" function on it which works like magic mock in python
- defining an initial state for the values of the stored properties if needed
- and providing above as a factory, such that the initial state could be set within the test
//describe(...
const factory = (
initialBannerMessage = '',
initialBannerType = 'Info'
) => {
const wrapper = shallowMount(Banner, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: {
banner: {
bannerMessage: initialBannerMessage,
bannerType: initialBannerType
}
}
})
]
}
}
)
const bannerStore = useBannerStore()
return { wrapper, bannerStore }
}
createSpy docs: https://pinia.vuejs.org/cookbook/testing.html#specifying-the-createspy-function
Assertions like these, which used the props
expect(wrapper.vm.messageToDisplay).toMatch('SUCCESS! Loaded user data!')
expect(wrapper.vm.messageType).toMatch('Success')
were refactored to be based on the bannerStore
expect(bannerStore.setBannerData).toHaveBeenCalledTimes(1)
expect(bannerStore.setBannerData).toHaveBeenLastCalledWith('', 'Info')
2 New cool assertions thanks to the usage of the spy function:
- toHaveBeenCalledTimes(integer) https://vitest.dev/api/expect.html#tohavebeencalledtimes
- toHaveBeenLastCalledWith(arg, ...) https://vitest.dev/api/expect.html#tohavebeencalledwith
I found that I sent the delete signal with smaller version of the user object.
In the actual delete method users.indexOf(user)
was used to get the index.
However, indexOf
expect an exact match, not a partial, at least for objects.
As a result I always got the index -1, which of course is wrong and indicates an error. Funny enough it still worked to delete one user, but not the one a human user would have wanted to.
I fixed it by sending the complete user
object as part of the call to emit
.
The router is an object to construct multiple routes for the application.
Rest principle. The way to implent this in vue is with vue-router
which
is used as a plugin like pinia.
The code for the routing is put in a separate folder.
.
├── index.html
├── src
│ ├── App.vue
│ ├── components
│ ├── router
├── index.js
The needed pieces of code are createRouter
to create a ... router and
an object to configure how the URL should work.
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: []
})
export default router
createWebHistory
will create URL in the structure of example.com/filename
.
However, they describe that web server config could have a difficulty with that.
The config nginx config described in the docs for that is:
location / {
try_files $uri $uri/ /index.html;
}
Source: https://router.vuejs.org/guide/essentials/history-mode.html#nginx
Interestingly, we also learned how to use environment variables here:
import.meta.env.BASE_URL
Ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta
To be reachable, the module needs to export
the variable.
In that case we can import the router variable using import { router } from '@/router/index'
.
Thx to the export default
we will not need to name the var and use import Alias from '@/router/index''
.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
The Individual routes are described with an object of three properties: path, name and component.
// routes
{
path: '/blog',
name: 'blog',
// route level code-splitting
// this generates a separate chunk (Blog.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/BlogView.vue')
}
The component can be described with import("path/to/the/module")
or
the component can be imported and used in the definition
The router is plugged in like pinia into the Vue application. Unlike pinia however, we plug in the configured var of our router module instead of the library.
import router from './router'
//
app.use(router)
Usually, in an SPA, the main/center component is switched out.
In our case the AppContent
component is switched out with a URL aware placeholder component
called RouterView
<script setup>
import { RouterView } from 'vue-router'
<script>
<template>
<AppContent>
<RouterView></RouterView>
// ...
</template>
Lastly, the current location can be highlighted on the HTML itself.
But for that, we need to use the RouterLink
element of the vue-router
<li><RouterLink to="/">Home</RouterLink></li>
This link component injects class="router-link-active
and router-link-exact-active
classes
into the a
HTML tag which is currently active.
Like pinia, the router is passed into the mount
function
const wrapper = mount(App, {
global: {
plugins: [
router,
createTestingPinia({
createSpy: vi.fn
})
]
}
});
If we want to test the site with specific route we will need to use:
await router.push('/')
in case we want to hit the landing page. That makes it necessary to
use the async
keyword on the test callback declaration.
If we want to test sth which depends on the current route
we will need to use mount
instead of shallowMount
(see tests for AppHeader
)