Vuetify 3 TypeScript Tutorial Series -Part 5

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

In Part 4 of this tutorial series, we covered the following steps:

  • Created first Vue components
  • Use of Pinia
  • Implement composable for task fetching

If you missed Part 4, you can find it here: Part 4

The TaskCard

We need a card component to display our tasks. But before we create the card component let us create another composable function to display the date properly, because our API sends the date in this form:

"createdOn": "2024–01–04T22:09:32.408047"

Create the file formatDate.ts in composables package:

/**
* Formats a date string to a specified locale and format.
*
* @param {string} date - The date string to format.
* @param {string} [locale='en-US'] - The locale to use for formatting.
* @param {Intl.DateTimeFormatOptions} [options] - The options for date format.
* @returns {string} Formatted date string.
*/
export const formatDate = (
date: string,
locale: string = 'en-US',
options: Intl.DateTimeFormatOptions = {year: 'numeric', month: '2-digit', day: '2-digit'}
): string => {
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) {
// Handle invalid date
console.warn('formattedDate: Invalid date provided');
return '';
}
return parsedDate.toLocaleDateString(locale, options).replace(/\//g, '.');
};

After that create in components package the file TaskCard.vue and add these lines:

<script lang="ts" setup>
import {useTaskStore} from "@/store/taskStore";
import {TaskFetchResponse} from "@/dtos/taskDto";
import {formatDate} from "@/composables/formatDate";

defineProps({
tasks: Array,
});

const taskStore = useTaskStore();
const emits = defineEmits(['card-clicked', 'delete-clicked', 'edit-clicked']);

const getBorderColorClass = (isTaskOpen: boolean) => {
if (isTaskOpen) {
return 'green-border';
} else {
return 'black-border';
}
};

function storeTask(task: TaskFetchResponse) {
taskStore.setTaskToEdit(task);
emits('card-clicked', task.id);
}

</script>

Props Definition:

  • defineProps({ tasks: Array }): Declares tasks as a prop that the component expects to receive, specified as an array.

Task Store Usage:

  • useTaskStore(): Initializes taskStore to use methods and state from the task store.

Event Emits Definition:

  • defineEmits(['card-clicked', 'delete-clicked', 'edit-clicked']): Defines the events that this component can emit to its parent components. These events are related to card interaction, deletion, and editing.

Helper Function:

  • getBorderColorClass(isTaskOpen: boolean): A function that returns a CSS class name based on the isTaskOpen boolean value. If isTaskOpen is true, it returns 'green-border'; otherwise, 'black-border'.

Component Logic Function:

  • storeTask(task: TaskFetchResponse): A function that takes a task object as an argument. It sets the current task to be edited in the task store and emits a 'card-clicked' event with the task's ID.

Overall, this script setup is used in a component to handle tasks displaying, provide styling based on task status, and manage interactions like selecting a task for editing or other actions (like clicking, deleting, editing). The component likely represents a part of a task management system.

Next, add the HTML structure to show the card or cards for the user:

<template>
<p class="center-content" v-if="tasks?.length === 0">No tasks have been created yet...</p>
<div v-if="tasks?.length > 0">
<v-card v-for="(task, index) in tasks" :key="index"
class="mx-auto v-card-bg nice-looking-card"
:class="getBorderColorClass(task.isTaskOpen)">
<v-card-item @click="storeTask(task)">
<div>
<div class="text-overline mb-2">
<v-card-text class="d-flex justify-space-between align-items-center">
<span
class="mdi mdi-traffic-light-outline"
v-if="task.priority !== null">Priority: {{ task.priority }}
</span>
<span class="mdi mdi-toggle-switch-off-outline">Reminder: {{ task.isReminderSet }}</span>
</v-card-text>
</div>
<div class="text-h6 mb-2 center-text">
{{ task.description }}
</div>
<div class="text-caption center-text">Created on: {{ formatDate(task.createdOn) }}</div>
</div>
</v-card-item>

<v-card-actions>
<v-btn color="blue" class="mr-2" @click="emits('edit-clicked', task)">
<v-icon start icon="mdi-pencil-outline"></v-icon>
Edit Task
</v-btn>

<v-btn color="red" class="ml-auto"
@click="emits('delete-clicked', {id: task.id, description: task.description})">
Delete Task
<v-icon end icon="mdi-trash-can-outline"></v-icon>
</v-btn>
</v-card-actions>
</v-card>
</div>
</template>

Lastly, for this component, we need to add the styling:

<style scoped>

.center-text {
text-align: center;
}

.black-border {
border: 2px solid black;
}

.green-border {
border: 2px solid green;
}

.center-content {
display: flex;
justify-content: center;
align-items: center;
height: 30vh;
}

</style>

Then remove in the package layouts -> default the file AppBar.vue and adapt the file default.vue like this:

<template>
<v-app>
<DefaultView />
</v-app>
</template>

<script lang="ts" setup>
import DefaultView from './default/View.vue'
</script>

Next hop to styles package and in settings.scssadd these lines:

.v-sheet-padding {
padding: 0.65rem;
}

.v-card-bg {
background-color: #EEEEEE;
margin: 0.85rem;
}

.nice-looking-card {
border-radius: 15px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
background-color: #f8f9fa;
padding: 20px;
overflow: hidden; /* Ensures the border-radius clips content */
}

.center-text {
text-align: center;
}

.v-card-bg {
background-color: #f5f5f5; /* Light background */
color: #333; /* Darker text for contrast */
}

.v-card-actions {
border-top: 1px solid #ddd; /* Subtle line to separate actions */
padding-top: 10px;
}

.v-btn {
text-transform: none; /* Avoid uppercase text */
}

.mdi {
font-size: 1rem; /* Adjust this value to increase or decrease the icon size */
}

.v-btn.clear-btn {
width: 49%;
background-color: red;
color: #EEEEEE;
margin: 0 0.2rem;

@media (max-width: 600px) {
width: 100%;
}
}

.v-btn.submit-btn {
width: 49%;
background-color: green;
color: #EEEEEE;
margin: 0 0.2rem;

@media (max-width: 600px) {
width: 100%;
}
}

This is the general styling for our web app.

Create your first Page

In src -> pages package delete the files index.vue and READMe.md. Then create the file TasksOverviewPage.vue and add these lines:

<script lang="ts" setup>
import {onMounted, ref, watch, watchEffect} from "vue";
import router from "@/router";
import {getTasks} from "@/composables/getTasks";
import {TASK_DETAIL_VIEW, TASK_UPDATE_VIEW} from "@/constants/appConstants";
import {TaskFetchResponse} from "@/dtos/taskDto";
import {useTaskNavigation} from "@/composables/useTaskNavigation";
import {useTaskStore} from "@/store/taskStore";
import MainBackground from "@/components/MainBackground.vue";
import Navbar from "@/components/Navbar.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import ErrorDialog from "@/components/ErrorDialog.vue";
import TaskCard from "@/components/TaskCard.vue";


const {fetchTasks, tasks, isLoading, isNetworkError, axiosError} = getTasks();
const {handleTaskTypeSelected, logoClicked} = useTaskNavigation();
const taskStore = useTaskStore();
const selectedTaskId = ref(0);
const isDeleteDialogSelected = ref(false);
const selectedTaskDescription = ref('');


onMounted(() => {
fetchTasks(taskStore.selectedTaskType);
});

watch(() => taskStore.selectedTaskType, (newType) => {
fetchTasks(newType);
});

watchEffect(() => {
fetchTasks(taskStore.selectedTaskType);
});


const openDeleteDialog = (task: { id: number, description: string }) => {
selectedTaskId.value = task.id;
selectedTaskDescription.value = task.description;
isDeleteDialogSelected.value = true;
};

const handleCardClicked = (id: number) => {
router.push({name: TASK_DETAIL_VIEW, params: {id: id.toString()}}).then();
};

const navigateToTaskUpdateView = (task: TaskFetchResponse) => {
taskStore.setTaskToEdit(task);
router.push({name: TASK_UPDATE_VIEW, params: {id: task.id.toString()}}).then();
};

const deleteTask = (id: number) => {
console.log("delete clicked");
};

</script>

Composable Usage:

  • getTasks(): Initializes task-related reactive states (tasks, isLoading, isNetworkError, axiosError) and the fetchTasks function for fetching tasks.
  • useTaskNavigation(): Provides navigation-related functions (handleTaskTypeSelected, logoClicked).
  • useTaskStore(): Accesses the task store state and methods.

Reactive State Initialization:

  • selectedTaskId, isDeleteDialogSelected, and selectedTaskDescription are initialized as reactive references.

Lifecycle Hooks and Watchers:

  • onMounted(): Calls fetchTasks when the component is mounted.
  • watch(): Observes changes in selectedTaskType and calls fetchTasks accordingly.
  • watchEffect(): Reactively calls fetchTasks based on changes in selectedTaskType.

Component Functions:

  • openDeleteDialog(): Sets selectedTaskId, selectedTaskDescription, and shows the delete dialog.
  • handleCardClicked(): Navigates to the task detail view.
  • navigateToTaskUpdateView(): Sets the task to edit and navigates to the task update view.
  • deleteTask(): Placeholder function for deleting a task (currently just logs to console).

Overall, this script sets up a component to display tasks, handle task navigation, and manage task-related interactions like viewing, editing, and deleting tasks.

Then add the HTML structure so we can see the result when it is fetched:

<template>
<Navbar @task-type-selected="handleTaskTypeSelected" @logo-clicked="logoClicked"/>
<MainBackground>
<ErrorDialog :model-value="isNetworkError" :axios-error="axiosError"/>
<TaskCard
:tasks="tasks"
@card-clicked="handleCardClicked"
@delete-clicked="openDeleteDialog"
@edit-clicked="navigateToTaskUpdateView"
/>
<LoadingSpinner :is-loading="isLoading"/>
</MainBackground>
</template>

Now before we can display anything, we need to adapt index.ts in router package:

// Composables
import {createRouter, createWebHistory} from 'vue-router'
import TasksOverviewPage from "@/pages/TasksOverviewPage.vue";
import {HOME_VIEW} from "@/constants/appConstants";


const routes = [
{
path: '/',
component: () => import('@/layouts/default.vue'),
children: [
{
path: "",
name: HOME_VIEW,
component: TasksOverviewPage,
props: true
},
],
}
]

const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})

export default router

Then just run in your Terminal

pnpm dev
start project

When click on the link http://localhost:3000/ you should see this:

Desktop version

When you right-click and select Inspect in Chrome Web Browser you should be able to see it on Mobile as well

Mobile version

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 6

Don’t forget to check out the video playlist on YouTube.

Here is the source code on GitHub, check out the branch: part-five

--

--