Vuetify 3 TypeScript Tutorial Series -Part 4

Habibi Coding | حبيبي كودنق
Nerd For Tech
Published in
6 min readFeb 1, 2024
tutorial banner

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

Navbar component

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) and modelValue (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 for update:modelValue.
  • A watch function monitors changes to props.modelValue and updates isDialogActive accordingly.
  • closeDialog is a function to close the dialog and emit an update:modelValue event with false, 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 from TaskState.
  • Two actions are defined in the store:
  • setTaskToEdit(task: TaskFetchResponse), which sets taskToEdit to the provided task.
  • setSelectedTaskType(taskType: string), which updates selectedTaskType 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 the selectedTaskType in the store based on the passed taskType. It then calls navigateToTasksView.
  • navigateToTasksView uses Vue Router to navigate to the home view (HOME_VIEW).
  • logoClicked sets the selectedTaskType to OPEN and navigates to the home view, similar to navigateToTasksView.
  • The function returns handleTaskTypeSelected, navigateToTasksView, and logoClicked, 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

--

--