Vuetify 3 TypeScript Tutorial Series -Part 4
In Part 3 of this tutorial series, we covered the following steps:
- We implemented the Axios HTTP client
- Implemented first composables method to fetch tasks
If you missed Part 3, you can find it here: Part 3
Time to add create first Vue component
Take a look at src
-> components
package and delete the
HelloWorld.vue and README.md files. Create a new file called Navbar.vue
Before we can continue with the implementation we have to create additional constant values first. So open again the file appConstants.ts
and these values:
export const OPEN_TASKS = 'Open Tasks';
export const CLOSED_TASKS = 'Closed Tasks';
export const ALL_TASKS = 'All Tasks';
export const HOME_VIEW = 'Home';
export const TASK_DETAIL_VIEW = 'TaskDetail';
export const TASK_CREATE_VIEW = 'TaskCreate';
export const TASK_UPDATE_VIEW = 'TaskUpdate';
export const MIN_TASK_DESCRIPTION = 3;
After that, you can open again Navbar.vue
and those lines:
<script setup lang="ts">
import router from "@/router";
import {computed, ref} from 'vue'
import {useDisplay} from 'vuetify'
import {ALL_TASKS, CLOSED_TASKS, OPEN_TASKS, TASK_CREATE_VIEW} from "@/constants/appConstants";
const display = useDisplay()
const isMobileDevice = computed(() => {
return display.mobile
})
const links: string[] = [OPEN_TASKS, CLOSED_TASKS, ALL_TASKS]
const drawer = ref(false);
const selectedLink = ref('');
const emit = defineEmits(['task-type-selected', 'logo-clicked']);
const selectTaskType = (taskType: string) => {
selectedLink.value = taskType;
emit('task-type-selected', taskType);
drawer.value = false;
};
const logoClicked = () => {
emit('logo-clicked');
};
const createTask = () => {
router.push({name: TASK_CREATE_VIEW}).then();
drawer.value = false;
};
</script>
useDisplay()
is used to determine if the device is mobile.isMobileDevice
is a computed property returning true if the current device is mobile.links
is an array of task-related constants for navigation links.drawer
is a reactive variable controlling the state of a navigation drawer, and will be used only for mobile views.selectedLink
tracks the currently selected link (OPEN_TASKS, CLOSED_TASKS, ALL_TASKS).defineEmits
creates an emit function for emitting custom events.selectTaskType
sets the selected link, emits a 'task-type-selected' event, and closes the drawer.logoClicked
emits a 'logo-clicked' event and will later navigate back to home view.createTask
navigates to the task creation view and closes the drawer.
Now, we implement the actual navbar for desktop and mobile browser:
<template>
<v-app-bar flat>
<!-- Container for logo and menu items -->
<v-container class="d-flex align-center justify-center">
<!-- Logo -->
<v-app-bar-title>
<v-img src="../assets/logo.png" max-height="70" max-width="70" @click="logoClicked"></v-img>
</v-app-bar-title>
<!-- Menu items for desktop view -->
<template v-if="!isMobileDevice.value">
<v-btn
v-for="link in links"
:key="link"
@click="selectTaskType(link)"
:text="link"
variant="text"
:class="{ 'selected-link': link === selectedLink }">
{{ link }}
</v-btn>
<v-spacer/>
<v-btn
class="text-none text-subtitle-1"
color="#05B990"
size="small"
variant="outlined"
@click="createTask">
Create Task
</v-btn>
</template>
<!-- Hamburger icon for mobile view -->
<v-app-bar-nav-icon v-if="isMobileDevice.value" @click="drawer = !drawer"></v-app-bar-nav-icon>
</v-container>
</v-app-bar>
<!-- Navigation drawer for mobile view -->
<v-navigation-drawer v-model="drawer" temporary v-if="isMobileDevice.value">
<v-list>
<v-list-item
v-for="link in links"
:key="link"
@click="selectTaskType(link)"
:class="{ 'selected-link': link === selectedLink }">
<v-list-item-title>{{ link }}</v-list-item-title>
</v-list-item>
<v-list-item @click="createTask">
<v-list-item-title>Create Task</v-list-item-title>
</v-list-item>
</v-list>
</v-navigation-drawer>
</template>
Lastly, add a style for the selected menu item:
<style scoped>
.selected-link {
color: green !important;
}
</style>
After that, we will add the MainBackground.vue
in components
package:
<template>
<v-app id="inspire">
<v-main class="bg-grey-lighten-3">
<v-container>
<v-row>
<v-col>
<v-sheet min-height="70vh" rounded="lg" class="v-sheet-padding">
<slot></slot>
</v-sheet>
</v-col>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
Next, we create a component for showing an error dialog when there is a network error, so create ErrorDialog.vue
in components
package and add these TS lines:
<script setup lang="ts">
import {defineProps, PropType, ref, watch} from "vue";
import {AxiosError} from "axios";
const props = defineProps({
axiosError: Object as PropType<AxiosError>,
modelValue: Boolean,
});
const isDialogActive = ref(false);
const emit = defineEmits(['update:modelValue']);
watch(() => props.modelValue, newVal => {
isDialogActive.value = newVal;
});
const closeDialog = () => {
isDialogActive.value = false;
emit('update:modelValue', false);
};
</script>
defineProps
sets up component props:axiosError
(an Axios error object) andmodelValue
(a boolean indicating if the dialog is visible).isDialogActive
is a reactive reference to control the dialog's visibility.defineEmits
creates a function to emit events, specifically forupdate:modelValue
.- A
watch
function monitors changes toprops.modelValue
and updatesisDialogActive
accordingly. closeDialog
is a function to close the dialog and emit anupdate:modelValue
event withfalse
, indicating the dialog is closed.
Then add the HTML structure for the error card:
<template>
<v-row justify="center">
<v-dialog v-model="isDialogActive" persistent width="auto">
<v-card>
<v-card-title class="text-h5">
An error occurred!
</v-card-title>
<v-card-text>
HTTP Status Code: {{ props.axiosError.response?.status || 'N/A' }}
</v-card-text>
<v-card-text>
Error message: {{ props.axiosError.message || 'No error message available' }}
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="red-darken-1" variant="text" @click="closeDialog">
OK
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
</template>
The next component we need is one to show a spinning loader so create in components
package the new file LoadingSpinner.vue
and add the script lines:
<script setup lang="ts">
defineProps({
isLoading: {
type: Boolean,
required: true
}
});
</script>
Add the HTML structure for LoadingSpinner.vue
:
<template>
<div class="loading-container" v-if="isLoading">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
</template>
Lastly, add the styling for loading progress:
<style scoped>
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 90%;
width: 100%;
position: absolute;
top: 0;
left: 0;
background-color: rgba(255, 255, 255, 0.8);
}
</style>
Use of Pinia
To save the selected menu item to trigger a new fetch from the endpoint we need to cache the user selection. In store
package create the file taskStore.ts
and add these lines:
import { defineStore } from 'pinia'
import {TaskFetchResponse, TaskState} from "@/dtos/taskDto";
export const useTaskStore = defineStore('task', {
state: () => ({
taskToEdit: Object as TaskFetchResponse | unknown,
selectedTaskType: TaskState[TaskState.OPEN],
}),
actions: {
setTaskToEdit(task: TaskFetchResponse) {
this.taskToEdit = task;
},
setSelectedTaskType(taskType: string) {
this.selectedTaskType = taskType;
},
},
})
defineStore
from Pinia is used to create a new store named 'task'.- The store’s
state
function returns an object with two properties: taskToEdit
, initially an empty object, meant to hold the task currently being edited.selectedTaskType
, initially set to the open state fromTaskState
.- Two actions are defined in the store:
setTaskToEdit(task: TaskFetchResponse)
, which setstaskToEdit
to the provided task.setSelectedTaskType(taskType: string)
, which updatesselectedTaskType
with a new task type.
Composable logic for task navigation
Next, we need logic to the menu items and also back to our home screen. In composables
create the file useTaskNavigation.ts
and add these lines:
import router from '@/router';
import {useTaskStore} from "@/store/taskStore";
import {TaskState} from "@/dtos/taskDto";
import {ALL_TASKS, CLOSED_TASKS, HOME_VIEW, OPEN_TASKS} from "@/constants/appConstants";
export function useTaskNavigation() {
const taskStore = useTaskStore();
const handleTaskTypeSelected = (taskType: string): void => {
switch (taskType) {
case OPEN_TASKS:
taskStore.selectedTaskType = TaskState[TaskState.OPEN];
break;
case CLOSED_TASKS:
taskStore.selectedTaskType = TaskState[TaskState.CLOSED];
break;
case ALL_TASKS:
taskStore.selectedTaskType = '';
break;
}
navigateToTasksView();
};
const navigateToTasksView = (): void => {
router.replace({name: HOME_VIEW}).then();
};
const logoClicked = (): void => {
taskStore.selectedTaskType = TaskState[TaskState.OPEN];
router.replace({name: HOME_VIEW}).then();
};
return {
handleTaskTypeSelected,
navigateToTasksView,
logoClicked,
};
}
- Initializes
taskStore
to manage task-related state. handleTaskTypeSelected
is a function that updates theselectedTaskType
in the store based on the passedtaskType
. It then callsnavigateToTasksView
.navigateToTasksView
uses Vue Router to navigate to the home view (HOME_VIEW
).logoClicked
sets theselectedTaskType
toOPEN
and navigates to the home view, similar tonavigateToTasksView
.- The function returns
handleTaskTypeSelected
,navigateToTasksView
, andlogoClicked
, making them available to components using this composable.
With that, we conclude the first part of this tutorial series. If you found it useful and informative, give it a clap. Here is Part 5
Don’t forget to check out the video playlist on YouTube.
Here is the source code on GitHub, check out the branch: part-four