Vuetify 3 TypeScript Tutorial Series -Part 5
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 })
: Declarestasks
as a prop that the component expects to receive, specified as an array.
Task Store Usage:
useTaskStore()
: InitializestaskStore
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 theisTaskOpen
boolean value. IfisTaskOpen
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.scss
add 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 thefetchTasks
function for fetching tasks.useTaskNavigation()
: Provides navigation-related functions (handleTaskTypeSelected
,logoClicked
).useTaskStore()
: Accesses the task store state and methods.
Reactive State Initialization:
selectedTaskId
,isDeleteDialogSelected
, andselectedTaskDescription
are initialized as reactive references.
Lifecycle Hooks and Watchers:
onMounted()
: CallsfetchTasks
when the component is mounted.watch()
: Observes changes inselectedTaskType
and callsfetchTasks
accordingly.watchEffect()
: Reactively callsfetchTasks
based on changes inselectedTaskType
.
Component Functions:
openDeleteDialog()
: SetsselectedTaskId
,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
When click on the link http://localhost:3000/ you should see this:
When you right-click and select Inspect
in Chrome Web Browser you should be able to see it on Mobile as well