postUuid | title | slug | tags | categories | ||||
---|---|---|---|---|---|---|---|---|
5d65c5d7-736e-4505-a28d-65cd6f60341b |
Quasar Tour of Heroes - Components |
quasar-toh-components |
|
|
This article starts from the situation where the layout is set up and the different pages are added to the Tour of Heroes application. The initial situation is an existing Quasar project with a layout component and placeholder pages. The final situation is a Quasar project in which the pages use components.
The code can be obtained by downloading a zip of the source code.
The situation that is used as a starting point is as follows:
Starting situation: Source code
The final situation being worked towards is as follows:
End situation: Source code
Do you have the following error in the template?
This is because the project was set up with an old version of
Volar
. This can be fixed by following this short blog.
Everything within Vue is a component. Since Quasar is a Vue framework, all components are also Vue components. In the application, you can already see a component for the layout, called MainLayout.vue
. There are also already components for the different pages.
The different pages are:
- Error404.vue - 404 page, by default present in a new Quasar project.
- Dashboard.vue - this is a page where the
top heroes
will be shown. - HeroList.vue - this is a page where the list of
heroes
will be shown. - HeroDetail.vue - this is a page where the details of a
hero
will be shown.
A layout is a component and contains HTML elements and other components that are present on all pages.
A page is a component and contains HTML elements and other components that are present on a specific page.
During this article, new components will be added to the application.
Change the content of src/pages/Dashboard.vue
:
<template>
<div>Top Heroes</div>
<div>Narco</div>
<div>Bombasto</div>
<div>Celeritas</div>
<div>Magneta</div>
</template>
The elements are present, but the styling does not match.
Add a style
tag to the Dashboard page:
<template>
<div class="title">Top Heroes</div>
<div class="top-heroes">
<div class="top-hero">Narco</div>
<div class="top-hero">Bombasto</div>
<div class="top-hero">Celeritas</div>
<div class="top-hero">Magneta</div>
</div>
</template>
<style lang="scss" scoped>
.title {
font-size: 1.5rem;
color: grey;
font-weight: bold;
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.top-heroes {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 2rem;
}
.top-hero {
display: flex;
justify-content: center;
align-items: center;
background-color: #5f7d8c;
color: white;
height: 8rem;
width: 10rem;
font-weight: 600;
font-size: 1.1rem;
&:hover {
background-color: #eeeeee;
color: #5f7d8c;
cursor: pointer;
}
}
</style>
Note that a container element
has been added around the hero
elements.
All changes: GitHub
Change the content of src/pages/HeroList.vue
:
<template>
<div>My Heroes</div>
<div>11 Mr. Nice</div>
<div>12 Narco</div>
<div>13 Bombasto</div>
<div>14 Celeritas</div>
<div>15 Magneta</div>
<div>16 RubberMan</div>
<div>17 Dynama</div>
<div>18 Dr IQ</div>
<div>19 Magma</div>
<div>20 Tornado</div>
</template>
Style the elements, add additional elements as needed to achieve the desired result.
<template>
<div class="title">My Heroes</div>
<div class="hero-list">
<div class="hero">
<span class="hero-number">11</span>
<span class="hero-name">Mr. Nice</span>
</div>
<div class="hero">
<span class="hero-number">12</span> <span class="hero-name">Narco</span>
</div>
<div class="hero">
<span class="hero-number">13</span>
<span class="hero-name">Bombasto</span>
</div>
<div class="hero">
<span class="hero-number">14</span>
<span class="hero-name">Celeritas</span>
</div>
<div class="hero">
<span class="hero-number">15</span> <span class="hero-name">Magneta</span>
</div>
<div class="hero">
<span class="hero-number">16</span>
<span class="hero-name">RubberMan</span>
</div>
<div class="hero">
<span class="hero-number">17</span> <span class="hero-name">Dynama</span>
</div>
<div class="hero">
<span class="hero-number">18</span> <span class="hero-name">Dr IQ</span>
</div>
<div class="hero">
<span class="hero-number">19</span> <span class="hero-name">Magma</span>
</div>
<div class="hero">
<span class="hero-number">20</span> <span class="hero-name">Tornado</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.title {
font-size: 1.5rem;
color: grey;
font-weight: bold;
margin-top: 1rem;
margin-bottom: 1rem;
}
.hero-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.hero {
display: flex;
max-width: 10rem;
background-color: darken(#eeeeee, 10%);
cursor: pointer;
color: #8d8d8d;
border-radius: 0.5rem;
&:hover {
background-color: #cfd8dc;
color: white;
margin-left: 0.25rem;
}
}
.hero-number {
display: flex;
justify-content: center;
align-items: center;
background-color: #5f7d8c;
color: white;
border-top-left-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
padding: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
}
.hero-name {
padding: 0.5rem;
padding-left: 0.75rem;
font-weight: 600;
}
</style>
All changes: GitHub
In order for a hero to be selected, a click handler
must be added to each hero. Since with the current code, there are ten separate elements representing a hero, ten click handlers
would be needed.
To avoid this, a v-for
loop in the template
section, combined with a list of heroes, can be used.
<template>
<div class="title">My Heroes</div>
<div class="hero-list">
<div class="hero" v-for="(hero, index) in heroes" :key="index">
<span class="hero-number">{{ hero.number }}</span>
<span class="hero-name">{{ hero.name }}</span>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
setup() {
const heroes = [
{ number: 11, name: "Mr. Nice" },
{ number: 12, name: "Narco" },
{ number: 13, name: "Bombasto" },
{ number: 14, name: "Celeritas" },
{ number: 15, name: "Magneta" },
{ number: 16, name: "RubberMan" },
{ number: 17, name: "Dynama" },
{ number: 18, name: "Dr IQ" },
{ number: 19, name: "Magma" },
{ number: 20, name: "Tornado" },
];
return {
heroes,
};
},
});
</script>
<style lang="scss" scoped>
/* No changes! */
</style>
It is important to add the v-for
loop in the template. This is done on the element that should be repeated. In order for change detection
to work optimally, a key
attribute must be used. This ensures that each element is uniquely identified.
A <script lang="ts"></script>
has been added. lang="ts"
means that TypeScript is used. The defineComponent
function is used. This function allows you to define a component. The setup
function is the function that is called when the component is created. The setup
function defines the heroes
list, which includes an object with the properties number
and name
. Each object represents a hero.
Variables used in the template
section must be in the return
statement of the setup
function. Only the variables listed here are available in the template.
In order for a hero to be selected, a few things are needed:
- a
click handler
on each hero element, this is a function that is called when a hero is clicked - a
selectedHero
variable that will contain the selected hero
<template>
<div class="title">My Heroes</div>
<div class="hero-list">
<div
class="hero"
v-for="(hero, index) in heroes"
:key="index"
@click="onClickHero(hero)"
>
<span class="hero-number">{{ hero.number }}</span>
<span class="hero-name">{{ hero.name }}</span>
</div>
</div>
<div>{{ selectedHero }}</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
setup() {
let selectedHero;
const heroes = [
{ number: 11, name: "Mr. Nice" },
{ number: 12, name: "Narco" },
{ number: 13, name: "Bombasto" },
{ number: 14, name: "Celeritas" },
{ number: 15, name: "Magneta" },
{ number: 16, name: "RubberMan" },
{ number: 17, name: "Dynama" },
{ number: 18, name: "Dr IQ" },
{ number: 19, name: "Magma" },
{ number: 20, name: "Tornado" },
];
function onClickHero(hero: { number: number; name: string }) {
selectedHero = hero;
}
return {
heroes,
selectedHero,
onClickHero,
};
},
});
</script>
<style lang="scss" scoped>
/* No changes! */
</style>
Remember: selectedHero
is a variable defined with the keyword let
. This is necessary since the variable will be assigned a different value at a later time.
The click handler
is added by means of the @click
directive. This directive is used to call a function when an element is clicked. The function that is called is defined in the setup
function.
The function that is called is defined as onClickHero
, with a hero as argument. This function is called when a hero is clicked.
The function modifies the selectedHero
variable. This variable is used in the template to show the selected hero {{ selectedHero }}
.
BUT when testing out this code, the selectedHero
variable will not be changed in the template. This is because the selectedHero
variable must be a ref
variable. A ref
variable is a variable that contains a reference to an element. This reference is used by Vue to know that the value of the variable has changed and therefore that this variable should be renewed in the template.
A brief explanation regarding ref
variables. Vue provides a function called ref
, this must be imported import { ref } from "vue";
. This function accepts any value as an argument and will return an object with the value assigned to the property called value
.
import { ref } from "vue";
let someVariable; // value is undefined
someVariable = "John Duck"; // value is "John Duck"
const someRef = ref(); // value is { value: undefined }
const someOtherRef = ref("John Duck"); // value is { value: "John Duck" }
someOtherRef.value = "John - The - Duck"; // value is { value: "John - The - Duck" }
Some differences to note, a ref
variable can always be assigned to a variable created with keyword const
. This is because the ref()
function returns an object, then a property of this object (called value
) is always used to assign a new value. So the variable itself is not assigned a new value, since it is still the same object.
Because the object being changed is being watched by Vue, Vue knows that this value has changed and can then send change detected
events to the template.
<script lang="ts">
import { defineComponent, ref } from "vue";
export default defineComponent({
setup() {
// let selectedHero = undefined;
const selectedHero = ref();
const heroes = [
{ number: 11, name: "Mr. Nice" },
{ number: 12, name: "Narco" },
{ number: 13, name: "Bombasto" },
{ number: 14, name: "Celeritas" },
{ number: 15, name: "Magneta" },
{ number: 16, name: "RubberMan" },
{ number: 17, name: "Dynama" },
{ number: 18, name: "Dr IQ" },
{ number: 19, name: "Magma" },
{ number: 20, name: "Tornado" },
];
function onClickHero(hero: { number: number; name: string }) {
selectedHero.value = hero;
}
return {
heroes,
selectedHero,
onClickHero,
};
},
});
</script>
Notice that selectedHero
has become a ref
variable. This is an object in which the value is kept in the property value
. So to change the value, the .value
property must be used. This can be seen in the onClickHero
function.
Try this code again and notice that the selectedHero
variable is now changed in the template after selecting a hero.
Why should the heroes
array not be changed to a ref
variable? This is not necessary, because the list of heroes is not changed. If the list of heroes did change, for example by adding a new hero by clicking a button, then this list does need to be changed.
The heroes
array should not be changed to a ref
variable, but it may be changed to a ref
variable. A simple rule to follow is make every variable used in the template, a ref
variable. This will ensure that every variable used in the template is reactive
. Vue will always display an up-to-date value for each reactive variable
in the template.
<script lang="ts">
import { defineComponent, ref } from "vue";
export default defineComponent({
setup() {
const selectedHero = ref();
const heroes = ref([
{ number: 11, name: "Mr. Nice" },
{ number: 12, name: "Narco" },
{ number: 13, name: "Bombasto" },
{ number: 14, name: "Celeritas" },
{ number: 15, name: "Magneta" },
{ number: 16, name: "RubberMan" },
{ number: 17, name: "Dynama" },
{ number: 18, name: "Dr IQ" },
{ number: 19, name: "Magma" },
{ number: 20, name: "Tornado" },
]);
function onClickHero(hero: { number: number; name: string }) {
selectedHero.value = hero;
}
return {
heroes,
selectedHero,
onClickHero,
};
},
});
</script>
Note that nothing changes in the template
. Vue automatically knows that a variable is a ref
variable and will automatically use the .value
property to display the value of the variable in the template.
All changes: GitHub
A small refactoring of the code to add an interface
and change the definition of the onClickHero
function to an anonymous arrow function.
<script lang="ts">
import { defineComponent, ref } from "vue";
interface Hero {
number: number;
name: string;
}
export default defineComponent({
setup() {
const selectedHero = ref();
const heroes = ref([
{ number: 11, name: "Mr. Nice" },
{ number: 12, name: "Narco" },
{ number: 13, name: "Bombasto" },
{ number: 14, name: "Celeritas" },
{ number: 15, name: "Magneta" },
{ number: 16, name: "RubberMan" },
{ number: 17, name: "Dynama" },
{ number: 18, name: "Dr IQ" },
{ number: 19, name: "Magma" },
{ number: 20, name: "Tornado" },
]);
const onClickHero = (hero: Hero) => {
selectedHero.value = hero;
};
return {
heroes,
selectedHero,
onClickHero,
};
},
});
</script>
The function has changed from a named function.
function onClickHero(hero: { number: number; name: string }) {
selectedHero.value = hero;
}
To an anonymous arrow function assigned to a variable called onClickHero
.
const onClickHero = (hero: { number: number; name: string }) => {
selectedHero.value = hero;
};
This does not change the functionality of the function. It is simply a preference in terms of syntax.
The interface is added to represent the complex type Hero
. This interface is not necessary, but it is a good way to make the code more readable.
For adding the Hero interface:
const onClickHero = (hero: { number: number; name: string }) => {
selectedHero.value = hero;
};
After adding the Hero interface:
interface Hero {
number: number;
name: string;
}
const onClickHero = (hero: Hero) => {
selectedHero.value = hero;
};
The advantage of this TypeScript interface, is that wherever a Hero object is used, the interface can be used to define the type.
All changes: GitHub
<template>
<div class="title">My Heroes</div>
<div class="hero-list">
<div
class="hero"
v-for="(hero, index) in heroes"
:key="index"
@click="onClickHero(hero)"
>
<span class="hero-number">{{ hero.number }}</span>
<span class="hero-name">{{ hero.name }}</span>
</div>
</div>
<div v-if="selectedHero">
<div class="title">{{ selectedHero.name }} is my hero</div>
<button>Details</button>
</div>
</template>
Note that the v-if
directive is used. This ensures that the element and all child elements are only shown if the variable selectedHero
is truthy
. Or in other words, if the variable has a value, the element is shown. Without the v-if
directive, the application would show an error, since the .name
property of the variable selectedHero
does not exist, since selectedHero
is undefined
at that point.
The elements are there, the styling is not yet correct.
Now that the selected hero is known, the styling of the selected hero must be changed. The name of the selected hero must be shown in all capital letters. The button must have the same styling as the existing buttons.
Adding a dynamic class:
<div
:class="{
'hero--active': hero.number === selectedHero?.number,
}"
></div>
Note that a v-bind
directive is used to allow variables to be used in assigning the class v-bind:class
or the short syntax :class
. This can be read as "the class hero--active
is added to this element if the statement is truthy
.
The statement hero.number === selectedHero?.number
evaluates to true
if the the number of the hero being iterated over is equal to the number of the selected hero.
Note the ?.
operator. This operator ensures that .number
is read only if selectedHero
has a value. If selectedHero
is undefined
, .number
is not read and the statement hero.number === selectedHero?.number
evaluates to false
.
The complete template:
<template>
<div class="title">My Heroes</div>
<div class="hero-list">
<div
:class="{
'hero--active': hero.number === selectedHero?.number,
}"
class="hero"
v-for="(hero, index) in heroes"
:key="index"
@click="onClickHero(hero)"
>
<span class="hero-number">{{ hero.number }}</span>
<span class="hero-name">{{ hero.name }}</span>
</div>
</div>
<div v-if="selectedHero">
<div class="title">{{ upperCase(selectedHero.name) }} is my hero</div>
<button>Details</button>
</div>
</template>
The modified SCSS class hero--active
:
.hero {
display: flex;
max-width: 10rem;
background-color: darken(#eeeeee, 10%);
cursor: pointer;
color: #8d8d8d;
border-radius: 0.5rem;
&:hover {
background-color: #cfd8dc;
color: white;
margin-left: 0.25rem;
}
&--active {
background-color: #cfd8dc;
color: white;
margin-left: 0.25rem;
}
}
.hero {
&:hover {
}
&--active {
}
}
Is similar to the following CSS:
.hero {
}
.hero:hover {
}
.hero--active {
}
Now when a hero is selected, the class hero--active
is added to the element.
All changes: GitHub
One way to display the name in uppercase is to place the name in an element and then use css to capitalize the name. However, this tutorial chooses to change the name using a JavaScript function.
<template>
<div class="title">My Heroes</div>
<div class="hero-list">
<div
:class="{
'hero--active': hero.number === selectedHero?.number,
}"
class="hero"
v-for="(hero, index) in heroes"
:key="index"
@click="onClickHero(hero)"
>
<span class="hero-number">{{ hero.number }}</span>
<span class="hero-name">{{ hero.name }}</span>
</div>
</div>
<div v-if="selectedHero">
<div class="title">{{ upperCase(selectedHero.name) }} is my hero</div>
<button>Details</button>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from "vue";
interface Hero {
number: number;
name: string;
}
export default defineComponent({
setup() {
const selectedHero = ref();
const heroes = ref([
{ number: 11, name: "Mr. Nice" },
{ number: 12, name: "Narco" },
{ number: 13, name: "Bombasto" },
{ number: 14, name: "Celeritas" },
{ number: 15, name: "Magneta" },
{ number: 16, name: "RubberMan" },
{ number: 17, name: "Dynama" },
{ number: 18, name: "Dr IQ" },
{ number: 19, name: "Magma" },
{ number: 20, name: "Tornado" },
]);
const onClickHero = (hero: Hero) => {
selectedHero.value = hero;
};
const upperCase = (str: string) => str.toUpperCase();
return {
heroes,
selectedHero,
onClickHero,
upperCase,
};
},
});
</script>
The function upperCase
is created in the setup
function. The function is added to the return
statement so that it is available in the template. The function is added to {{ selectedHero.name }}
so that the name is displayed in uppercase: {{ upperCase(selectedHero.name) }}
.
All changes: GitHub
Since upperCase
is a function that returns any string in uppercase, it is ideal to put the function in a separate location so that the function can be imported in different places.
This tutorial chooses to place the function in a file called utils.ts
in the components
folder.
components/utils.ts
:
const upperCase = (str: string) => str.toUpperCase();
export { upperCase };
Importing the upperCase
function into the HeroList
component:
<script lang="ts">
import { defineComponent, ref } from "vue";
import { upperCase } from "components/utils";
interface Hero {
number: number;
name: string;
}
export default defineComponent({
setup() {
const selectedHero = ref();
const heroes = ref([
{ number: 11, name: "Mr. Nice" },
{ number: 12, name: "Narco" },
{ number: 13, name: "Bombasto" },
{ number: 14, name: "Celeritas" },
{ number: 15, name: "Magneta" },
{ number: 16, name: "RubberMan" },
{ number: 17, name: "Dynama" },
{ number: 18, name: "Dr IQ" },
{ number: 19, name: "Magma" },
{ number: 20, name: "Tornado" },
]);
const onClickHero = (hero: Hero) => {
selectedHero.value = hero;
};
return {
heroes,
selectedHero,
onClickHero,
upperCase,
};
},
});
</script>
Note that the upperCase
function is now imported and is no longer defined in the setup
function. Also note that the upperCase
function is still placed in the return
statement of the setup
function so that it is available in the template.
All changes: GitHub
Since the button should be styled the same as the already existing buttons in MainLayout.vue
, the styling can be copied there and then pasted into the style
tag of HeroList.vue
.
button {
background-color: #eeeeee;
border-radius: 0.25rem;
font-weight: 500;
border: none;
padding: 0.25rem 0.5rem;
color: #567868;
&:hover {
background-color: darken(#eeeeee, 10%);
color: #0096e8;
cursor: pointer;
}
}
This is a quick fix, but it creates a problem. What if the background-color
should no longer be #eeeeee
, but deeppink
? Then this must be changed in both the HeroList.vue
and the MainLayout.vue
.
To avoid this, a separate component can be created in which the button as well as the styling is placed.
components/StyledButton.vue
<template>
<button>Details</button>
</template>
<style lang="scss" scoped>
button {
background-color: #eeeeee;
border-radius: 0.25rem;
font-weight: 500;
border: none;
padding: 0.25rem 0.5rem;
color: #567868;
&:hover {
background-color: darken(#eeeeee, 10%);
color: #0096e8;
cursor: pointer;
}
}
</style>
The custom component can be imported into the HeroList.vue
:
<script lang="ts">
import { defineComponent, ref } from "vue";
import { upperCase } from "components/utils";
import StyledButton from "components/StyledButton.vue"; // 1.
interface Hero {
number: number;
name: string;
}
export default defineComponent({
components: {
StyledButton, // 2.
},
setup() {
const selectedHero = ref();
const heroes = ref([
{ number: 11, name: "Mr. Nice" },
{ number: 12, name: "Narco" },
{ number: 13, name: "Bombasto" },
{ number: 14, name: "Celeritas" },
{ number: 15, name: "Magneta" },
{ number: 16, name: "RubberMan" },
{ number: 17, name: "Dynama" },
{ number: 18, name: "Dr IQ" },
{ number: 19, name: "Magma" },
{ number: 20, name: "Tornado" },
]);
const onClickHero = (hero: Hero) => {
selectedHero.value = hero;
};
return {
heroes,
selectedHero,
onClickHero,
upperCase,
};
},
});
</script>
Using the custom component:
<div v-if="selectedHero">
<div class="title">{{ upperCase(selectedHero.name) }} is my hero</div>
<!-- Option 1 -->
<styled-button></styled-button>
<!-- Option 2 -->
<styled-button />
<!-- Option 3 -->
<StyledButton></StyledButton>
<!-- Option 4 -->
<StyledButton />
</div>
The custom component can be used in several ways.
As kebab-case
syntax, with start and end tags:
<styled-button></styled-button>
.
As kebab-case
syntax, self-closing tag:
<styled-button />
As PascalCase
-syntax, with beginning and ending tag:
<StyledButton></StyledButton>
.
As PascalCase
syntax, self-closing tag:
<StyledButton />
Within this tutorial, PascalCase
syntax is chosen. If possible the self-closing tag, if there is content between the start and end tags, then PascalCase
syntax with start and end tags is used.
<div v-if="selectedHero">
<div class="title">{{ upperCase(selectedHero.name) }} is my hero</div>
<StyledButton />
</div>
The problem with the current StyledButton
, is that it is hardcoded to contain the text Details
. But in the MainLayout.vue
component, it should display Dashboard
and Heroes
.
A normal button
element in html displays the text between a start and end tag. To obtain this in the custom component, a slot
tag can be used.
components/StyledButton.vue
<template>
<button>
<slot></slot>
</button>
</template>
Or with a self closing slot tag:
<template>
<button>
<slot />
</button>
</template>
All changes: GitHub
It is now possible to also use the button in the MainLayout.vue
component. The styling of the button
can be taken from the style
tag of the MainLayout.vue
component.
All changes: GitHub
When the Details
button is clicked, navigation to the route /heroes/:id
should occur. Where :id is the number
property of the selected hero.
pages/HeroList.vue
template
<StyledButton @click="onDetailsClick()">Details</StyledButton>
script-tag
<script lang="ts">
import { defineComponent, ref, Ref } from "vue";
import { upperCase } from "components/utils";
import { useRouter } from "vue-router";
import StyledButton from "components/StyledButton.vue";
import { ROUTE_NAMES } from "src/router/routes";
interface Hero {
number: number;
name: string;
}
export default defineComponent({
components: {
StyledButton,
},
setup() {
const router = useRouter();
const selectedHero = ref();
const heroes = ref([
{ number: 11, name: "Mr. Nice" },
{ number: 12, name: "Narco" },
{ number: 13, name: "Bombasto" },
{ number: 14, name: "Celeritas" },
{ number: 15, name: "Magneta" },
{ number: 16, name: "RubberMan" },
{ number: 17, name: "Dynama" },
{ number: 18, name: "Dr IQ" },
{ number: 19, name: "Magma" },
{ number: 20, name: "Tornado" },
]);
const onClickHero = (hero: Hero) => {
selectedHero.value = hero;
};
const onDetailsClick = () => {
void router.push({
name: ROUTE_NAMES.HERO_DETAILS,
params: {
id: selectedHero.value.number,
},
});
};
return {
heroes,
selectedHero,
onClickHero,
upperCase,
onDetailsClick,
};
},
});
</script>
In JavaScript, this code would work, but because it uses TypeScript, some errors are shown:
Compiled with problems:X
ERROR in src/pages/HeroList.vue:66:11
@typescript-eslint/no-unsafe-assignment: Unsafe assignment of an `any` value.
64 | name: ROUTE_NAMES.HERO_DETAILS,
65 | params: {
> 66 | id: selectedHero.value.number,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
67 | },
68 | });
69 | };
ERROR in src/pages/HeroList.vue:66:15
@typescript-eslint/no-unsafe-member-access: Unsafe member access .number on an `any` value.
64 | name: ROUTE_NAMES.HERO_DETAILS,
65 | params: {
> 66 | id: selectedHero.value.number,
| ^^^^^^^^^^^^^^^^^^^^^^^^^
67 | },
68 | });
69 | };
The problem is that selectedHero
is a ref
variable. This is of type any
by default.
import { ref } from "vue";
const selectedHero = ref();
Is equal to:
import { ref, Ref } from "vue";
const selectedHero: Ref<any> = ref();
Since it is known what will be in the ref
variable, namely a hero, the interface can be used to indicate that the ref
variable will contain a Hero
object.
import { ref, Ref } from "vue";
interface Hero {
number: number;
name: string;
}
const selectedHero: Ref<Hero> = ref();
Nice, solved. Right? No! There is a new compilation error. The error indicates that the ref
variable can only contain a Hero
object, but it is also assigned undefined
.
Compiled with problems:
ERROR in src/pages/HeroList.vue:43:11
TS2322: Type 'Ref<Hero | undefined>' is not assignable to type 'Ref<Hero>'.
Type 'Hero | undefined' is not assignable to type 'Hero'.
Type 'undefined' is not assignable to type 'Hero'.
41 | setup() {
42 | const router = useRouter();
> 43 | const selectedHero: Ref<Hero> = ref();
| ^^^^^^^^^^^^
44 |
45 | const heroes = ref([
46 | { number: 11, name: 'Mr. Nice' },
There are two ways to fix this error.
The first option is to add undefined
as a possible type of this ref
variable.
import { ref, Ref } from "vue";
interface Hero {
number: number;
name: string;
}
const selectedHero: Ref<Hero | undefined> = ref();
The disadvantage of this approach is that a ?
sign must always be used when addressing a property of the object in the .value
property, since it may be undefined
.
console.log(selectedHero.value?.number);
console.log(selectedHero.value?.name);
The second option is to define the ref
variable as Ref<Hero>
and type cast
the ref itself to Ref<Hero>
.
import { ref, Ref } from "vue";
interface Hero {
number: number;
name: string;
}
const selectedHero: Ref<Hero> = <Ref<Hero>>ref();
Or the alternative type cast
-syntax:
import { ref, Ref } from "vue";
interface Hero {
number: number;
name: string;
}
const selectedHero: Ref<Hero> = ref() as Ref<Hero>;
Now a property of the object in the .value
-property can be called without the ?
sign.
console.log(selectedHero.value.number);
console.log(selectedHero.value.name);
To ensure that it is always clear what the exact type is of the value in the ref
variable. Is it smart to always assign a type to the ref
variable.
<script lang="ts">
import { defineComponent, ref, Ref } from "vue";
import { upperCase } from "components/utils";
import { useRouter } from "vue-router";
import StyledButton from "components/StyledButton.vue";
import { ROUTE_NAMES } from "src/router/routes";
interface Hero {
number: number;
name: string;
}
export default defineComponent({
components: {
StyledButton,
},
setup() {
const router = useRouter();
const selectedHero: Ref<Hero> = ref() as Ref<Hero>;
const heroes = ref([
{ number: 11, name: "Mr. Nice" },
{ number: 12, name: "Narco" },
{ number: 13, name: "Bombasto" },
{ number: 14, name: "Celeritas" },
{ number: 15, name: "Magneta" },
{ number: 16, name: "RubberMan" },
{ number: 17, name: "Dynama" },
{ number: 18, name: "Dr IQ" },
{ number: 19, name: "Magma" },
{ number: 20, name: "Tornado" },
]);
const onClickHero = (hero: Hero) => {
selectedHero.value = hero;
};
const onDetailsClick = () => {
void router.push({
name: ROUTE_NAMES.HERO_DETAILS,
params: {
id: selectedHero.value.number,
},
});
};
return {
heroes,
selectedHero,
onClickHero,
upperCase,
onDetailsClick,
};
},
});
</script>
When testing the code, click on the hero Magneta
and then click on the Details
button. Verify that the browser url has been changed to /heroes/15
.
All changes: GitHub
First, all the elements needed to show the details of a hero are added.
<template>
<div v-if="hero">
<div class="title">{{ hero.name }} details!</div>
<div>id: {{ hero.number }}</div>
<div>name: <input :value="hero.name" /></div>
<StyledButton>Back</StyledButton>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, Ref } from "vue";
import StyledButton from "components/StyledButton.vue";
interface Hero {
number: number;
name: string;
}
export default defineComponent({
components: {
StyledButton,
},
setup() {
const hero: Ref<Hero> = ref() as Ref<Hero>;
return {
hero,
};
},
});
</script>
<style lang="scss" scoped>
.title {
font-size: 1.5rem;
color: grey;
font-weight: bold;
margin-top: 1rem;
margin-bottom: 1rem;
}
</style>
Now when a hero is selected, or when manually navigating to /heroes/:id
(e.g. /heroes/15
), the HeroDetails
page is shown. But nothing is shown in the browser. To style the elements, a hero can be temporarily hard-coded.
const hero: Ref<Hero> = ref({ number: 15, name: "Magneta" }) as Ref<Hero>;
All changes: GitHub
Before proceeding with styling the page, look at what is duplicated on this page relative to the HeroList
page. Duplicated things are candidates to be set apart so that it is reusable.
The Hero
interface was copied and pasted from the HeroList
page.
interface Hero {
number: number;
name: string;
}
Suppose an additional property is added to the Hero
interface, it would have to be changed in both the HeroList
page and the HeroDetails
page. This may not seem like a big deal, but consider what needs to be done if this interface is used in ten different components.
This is solved by placing the interface in src/components/models.ts
. From this file the interface is exported and then the interface is imported into all the files where this interface is used.
src/components/models.ts
:
export interface Hero {
number: number;
name: string;
}
Change the existing Hero
interface in the two places where it is used in the components, to an import that references the interface in src/components/models.ts
.
import { Hero } from "src/components/models";
All changes: GitHub
The class .title
is also used several times.
src/layouts/MainLayout.vue
.title {
font-size: 1.5rem;
color: grey;
font-weight: bold;
}
src/pages/Dashboard.vue
.title {
font-size: 1.5rem;
color: grey;
font-weight: bold;
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
src/pages/HeroDetails.vue
.title {
font-size: 1.5rem;
color: grey;
font-weight: bold;
margin-top: 1rem;
margin-bottom: 1rem;
}
src/pages/HeroList.vue
.title {
font-size: 1.5rem;
color: grey;
font-weight: bold;
margin-top: 1rem;
margin-bottom: 1rem;
}
The common properties of this class can be moved to the global stylesheet src/css/app.scss
.
All changes: GitHub
To make sure that the correct hero can be displayed, we will look at the url parameter called id
. Then the hero will be filtered from the list of heroes, which in this article will be copied from the HeroList
page, in a later article the list of heroes will be moved to a service
so that only one unique list of heroes exists.
First, the heroes
list is copied and pasted from the HeroList
page.
const heroes = ref([
{ number: 11, name: "Mr. Nice" },
{ number: 12, name: "Narco" },
{ number: 13, name: "Bombasto" },
{ number: 14, name: "Celeritas" },
{ number: 15, name: "Magneta" },
{ number: 16, name: "RubberMan" },
{ number: 17, name: "Dynama" },
{ number: 18, name: "Dr IQ" },
{ number: 19, name: "Magma" },
{ number: 20, name: "Tornado" },
]);
To get to the route parameter, the useRoute
composable must be used. This is a function that returns a route object, available through vue-router
.
import { useRoute } from "vue-router";
export default defineComponent({
setup() {
const route = useRoute();
console.log(route.params.id); // Visiting /heroes/15, this will log 15
},
});
Next, the onBeforeMount
hook is used, which is a lifecycle hook
. This hook is called after Vue has finished setting up the reactive data, but before any DOM elements have been created.
import { defineComponent, onBeforeMount, ref, Ref } from "vue";
import StyledButton from "components/StyledButton.vue";
import { Hero } from "components/models";
import { useRoute } from "vue-router";
export default defineComponent({
components: {
StyledButton,
},
setup() {
const route = useRoute();
const hero: Ref<Hero> = ref() as Ref<Hero>;
onBeforeMount(() => {
const { id } = route.params;
if (id) {
const matchingHero = heroes.value.find((h) => h.number === +id);
if (matchingHero) hero.value = matchingHero;
}
});
const heroes = ref([
{ number: 11, name: "Mr. Nice" },
{ number: 12, name: "Narco" },
{ number: 13, name: "Bombasto" },
{ number: 14, name: "Celeritas" },
{ number: 15, name: "Magneta" },
{ number: 16, name: "RubberMan" },
{ number: 17, name: "Dynama" },
{ number: 18, name: "Dr IQ" },
{ number: 19, name: "Magma" },
{ number: 20, name: "Tornado" },
]);
return {
hero,
};
},
});
Note that const hero = ref() as Ref<Hero>
no longer contains the hard-coded hero. It is now assigned based on the id
parameter. Test the code in two ways. The first way is to go to the list of heroes via the dashboard, click on a hero there and then click on the Details
button. The second way is to manually visit the route, for example /heroes/15
. Also try to manually enter an id of a hero that does not appear in the list, for example /heroes/69
.
All changes: GitHub
To provide the user with better feedback when entering a hero that does not exist, a Hero not found!
message will be displayed if the hero
-ref is empty. This is done by using the v-else
directive. An alternative is to use the v-if
directive with the !
operator.
<div v-if="hero">Hero exists!</div>
<div v-else class="title">Hero not found!</div>
Alternative with v-if:
<div v-if="!hero" class="title">Hero not found!</div>
Applied in the code:
<template>
<div v-if="hero">
<div class="title">{{ hero.name }} details!</div>
<div>id: {{ hero.number }}</div>
<div>name: <input :value="hero.name" /></div>
<StyledButton>Back</StyledButton>
</div>
<div v-else class="title">Hero not found!</div>
</template>
All changes: GitHub
To place the button correctly, a class is added to the button. To ensure that the button navigates back to the HeroList
page when clicked, a click handler
must be attached.
<StyledButton class="back-button" @click="moveBack()">Back</StyledButton>
.back-button {
margin-top: 1rem;
}
const moveBack = () => void router.push({ name: ROUTE_NAMES.HERO_LIST });
Remember to put the moveBack
function in the return
statement so that it is available in the template
tag.
All changes: GitHub
Currently, v-bind:value
, or the shortened syntax :value
is used to assign the value to the input. This is one way binding
, the input is assigned a value, but if the value is changed, it is not propagated to the hero
ref. To obtain two way binding
, use v-model
.
v-model
assigns the value of the hero
ref to the input, but will also update the value of the hero
ref when the input value is changed.
One way binding :value
<div>name: <input :value="hero.name" /></div>
Two way binding v-model
<div>name: <input v-model="hero.name" /></div>
All changes: GitHub
A page can be broken down into reusable components.
It is okay if components are not created up front, but added when it is noticed that there are identical blocks of code throughout the code base.
It is important to identify potential components. The more experience, the better identifying components will go.