This lesson will tackle two things: choosing categories for the tasks in the form/table and filtering the tasks by category.
Create Task Form: Choose Category
First, we need to modify the Controller to use Categories. I will add the code for both Create and Edit forms right away.
app/Http/Controllers/TaskController.php
use App\Models\TaskCategory; // ... public function create(){ return Inertia::render('Tasks/Create'); return Inertia::render('Tasks/Create', [ 'categories' => TaskCategory::all(), ]);} // ... public function edit(Task $task){ $task->load(['media']); $task->load(['media', 'taskCategories']); $task->append('mediaFile'); return Inertia::render('Tasks/Edit', [ 'task' => $task, 'categories' => TaskCategory::all(), ]);}// ...
Next, we need to add task_categories
to the Task
type:
resources/js/types/index.d.ts
// ... export interface Task { id: number; name: string; is_completed: boolean; due_date?: string; mediaFile?: MediaFile; task_categories: TaskCategory[]; created_at: string; updated_at: string;}
Then, we can add the parameter to our Create page. For selecting the categories for the task, there are many UI options like Select or Ratio, but I've chosen to try an interesting Shadcn component called Toggle Group. It is described as "A set of two-state buttons that can be toggled on or off."
npx shadcn-vue@latest add toggle-group
Interesting point: when installing, it prompted me to override the existing components. You may choose Yes
to update them with the newest component versions, but if you made any manual changes to the components, you should probably choose No
:
We add this component to the Create form, along with other required changes.
resources/js/Pages/Tasks/Create.vue
<script>import { BreadcrumbItem } from '@/types';import { BreadcrumbItem, TaskCategory } from '@/types';import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; // ... interface Props { categories: TaskCategory[];} const props = defineProps<Props>(); const categories = props.categories; const df = new DateFormatter('en-US', { dateStyle: 'long',}); const form = useForm({ name: '', due_date: '', media: '', categories: [],}); // ...</script> <template> <AppLayout :breadcrumbs="breadcrumbs"> <Head title="Create Task" /> <div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4"> <form class="space-y-6" @submit.prevent="submitForm"> // ... <div class="grid gap-2"> <Label htmlFor="categories">Categories</Label> <ToggleGroup type="multiple" variant="outline" size="lg" v-model="form.categories"> <ToggleGroupItem v-for="category in categories" :key="category.id" :value="category.id"> {{ category.name }} </ToggleGroupItem> </ToggleGroup> <InputError :message="form.errors.categories" /> </div> <div class="flex items-center gap-4"> <Button :disabled="form.processing" variant="default">Create Task</Button> </div> </form> </div> </AppLayout></template>
Behind the scenes, I've added three categories to the database:
With those, here's the visual result for the Create Task form:
Saving Data to the DB
We need Controller and Form Request changes:
app/Http/Requests/StoreTaskRequest.php
public function rules(): array{ return [ 'name' => ['required', 'string', 'max:255'], 'due_date' => ['nullable', 'date'], 'media' => ['nullable', 'file', 'max:10240'], 'categories' => ['nullable', 'array'], 'categories.*' => ['exists:task_categories,id'], ];}
In our Controller, we will swap from ->validated()
to ->safe()
to allow the categories
field to be passed through:
Note: Without this change, we would get an error since ->validated(['name', 'due_date'])
would just resolve to null
.
app/Http/Controllers/TaskController.php
use App\Models\TaskCategory; // ... public function create(){ return Inertia::render('Tasks/Create'); return Inertia::render('Tasks/Create', [ 'categories' => TaskCategory::all(), ]);} public function store(StoreTaskRequest $request){ $task = Task::create($request->validated() + ['is_completed' => false]); $task = Task::create($request->safe(['name', 'due_date']) + ['is_completed' => false]); if ($request->hasFile('media')) { $task->addMedia($request->file('media'))->toMediaCollection(); } if ($request->has('categories')) { $task->taskCategories()->sync($request->validated('categories')); } return redirect()->route('tasks.index');}
Now that the categories are saved in the database, we need to display them in the Tasks table.
Show Categories in the Tasks List Table
First, we need to eager load the categories in the Task Controller.
app/Http/Controllers/TaskController.php:
public function index(){ return Inertia::render('Tasks/Index', [ 'tasks' => Task::with('media')->paginate(20) 'tasks' => Task::with('media', 'taskCategories')->paginate(20) ]);}
And then, let's add the categories to the Vue page tasks list: a new <TableHead>
and a new <TableCell>
with the v-if
attribute.
resources/js/Pages/Tasks/Index.vue
<template>// ... <Table class="mt-4"> <TableHeader> <TableRow> <TableHead>Task</TableHead> <TableHead>File</TableHead> <TableHead class="w-[200px]">Categories</TableHead> <TableHead class="w-[200px]">Status</TableHead> <TableHead class="w-[200px]">Due Date</TableHead> <TableHead class="w-[200px] text-right">Actions</TableHead> </TableRow> </TableHeader> <TableBody> <TableRow v-for="task in tasks.data" :key="task.id"> <TableCell>{{ task.name }}</TableCell> <TableCell> <a v-if="task.mediaFile" :href="task.mediaFile.original_url" target="_blank"> <img :src="task.mediaFile.original_url" class="h-8 w-8" /> </a> </TableCell> <TableCell> <span v-for="category in task.task_categories" :key="category.id" class="mr-2 rounded-full bg-gray-200 px-2 py-1 text-gray-800" > {{ category.name }} </span> </TableCell> <TableCell :class="{ 'text-green-600': task.is_completed, 'text-red-700': !task.is_completed }"> {{ task.is_completed ? 'Completed' : 'In Progress' }} </TableCell> <TableCell>{{ task.due_date ? df.format(new Date(task.due_date)) : '' }}</TableCell> <TableCell class="flex gap-x-2 text-right"> <Link :class="buttonVariants({ variant: 'default' })" :href="`/tasks/${task.id}/edit`">Edit </Link> <Button variant="destructive" @click="deleteTask(task.id)" class="mr-2">Delete</Button> </TableCell> </TableRow> </TableBody></Table></template>
Here's the visual result:
Edit Form Changes
Next, we need to update the Edit page with changes similar to what we made in the Create form.
resources/js/pages/Tasks/Edit.vue
<script>import { BreadcrumbItem, Task } from '@/types'; import { BreadcrumbItem, Task, TaskCategory } from '@/types'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; // ... interface Props { task: Task; categories: TaskCategory[];} // ... const form = useForm({ name: task.name, is_completed: task.is_completed, due_date: task.due_date ? fromDate(new Date(task.due_date)) : null, media: '', categories: task.task_categories.map((category) => category.id)}); // ...</script> <template> <AppLayout :breadcrumbs="breadcrumbs"> <Head title="Edit Task" /> <div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4"> <form class="space-y-6" @submit.prevent="submitForm"> // ... <div class="grid gap-2"> <Label htmlFor="categories">Categories</Label> <ToggleGroup type="multiple" variant="outline" size="lg" v-model="form.categories"> <ToggleGroupItem v-for="category in categories" :key="category.id" :value="category.id"> {{ category.name }} </ToggleGroupItem> </ToggleGroup> <InputError :message="form.errors.categories" /> </div> <div class="flex items-center gap-4"> <Button :disabled="form.processing" variant="default">Update Task</Button> </div> </form> </div> </AppLayout></template>
Here's the visual result for the Edit form:
To save the data, we have to update our Form Request and Controller:
app/Http/Requests/UpdateTaskRequest.php
public function rules(): array{ return [ 'name' => ['required', 'string', 'max:255'], 'is_completed' => ['required', 'boolean'], 'due_date' => ['nullable', 'date'], 'media' => ['nullable', 'file', 'max:10240'], 'categories' => ['nullable', 'array'], 'categories.*' => ['exists:task_categories,id'], ];}
app/Http/Controllers/TaskController.php
// ... public function update(UpdateTaskRequest $request, Task $task){ $task->update($request->validated()); if ($request->hasFile('media')) { $task->getFirstMedia()?->delete(); $task->addMedia($request->file('media'))->toMediaCollection(); } $task->taskCategories()->sync($request->validated('categories', [])); return redirect()->route('tasks.index');}// ...
Great, now our Tasks CRUD contains the Category selection!
Filter in Tasks Table
The final thing in this lesson is to show the Tasks List with the ability to filter by categories.
First, on the front end, we add the categories filter to the top of the list as a simple list of buttons.
Also, our Vue component now needs to accept two more parameters: categories
to choose from and selectedCategories
the user selected.
Finally, we define the method selectCategory()
to process the category ID and visit the updated URL.
resources/js/Pages/Tasks/Index.vue
<script>import { type BreadcrumbItem, PaginatedResponse, Task, TaskCategory } from '@/types'; // ... interface Props { tasks: PaginatedResponse<Task>; categories: TaskCategory[]; selectedCategories: [];} defineProps<Props>();const props = defineProps<Props>();const selectedCategories = props.selectedCategories ? props.selectedCategories : []; // ... const selectCategory = (id: string) => { const selected = selectedCategories.includes(id) ? selectedCategories.filter((category) => category !== id) : [...selectedCategories, id]; router.visit('/tasks', { data: { categories: selected } });};</script> <template> <AppLayout :breadcrumbs="breadcrumbs"> <Head title="Tasks List" /> // ... <div class="mt-4 flex flex-row justify-center gap-x-2"> <Button v-for="category in categories" :key="category.id" @click="selectCategory(category.id.toString())" :class="buttonVariants({ variant: (selectedCategories.includes(category.id.toString()) ? 'default' : 'secondary') })"> {{ category.name }} ({{ category.tasks_count }}) </Button> </div> <Table class="mt-4"> // ...
We also need to make the appropriate changes to the Controller to load the category data and filter the tasks by chosen categories.
app/Http/Controllers/TaskController.php
use Illuminate\Http\Request; // ... public function index()public function index(Request $request){ return Inertia::render('Tasks/Index', [ 'tasks' => Task::query() ->with(['media', 'taskCategories']) ->when($request->has('categories'), function ($query) use ($request) { $query->whereHas('taskCategories', function ($query) use ($request) { $query->whereIn('id', $request->query('categories')); }); }) ->paginate(20) ->withQueryString(), 'categories' => TaskCategory::whereHas('tasks')->withCount('tasks')->get(), 'selectedCategories' => $request->query('categories'), ]);}
Now, you can see the filter above the table!
If you click any category, the tasks list will be filtered only for that category:
And also, you can filter by multiple categories!
Here is the GitHub commit for this lesson.
Hello, you saved lots of working hours with the paginatin component :) Thx!
But it is not working with the category filter. To make it work, I did the following:
In the index method of TaskController I added "->withQueryString()" after "->paginate(10)"
In Pagination.vue I changed v-on:click="() => router.visit(props.resource.path + '?page=' + item.value)" to: v-on:click="() => router.visit(props.resource.links[index+1].url)
Interesting, I might have missed this case when doing the testing!
But at least the solution isn't too complicated, so I'll update the article if it's needed.
Thank you for letting us know!
The problem was, that the pagination pages' links missing the categorieId values from the query string. So if you filter for a category and jump to page 2, the filter disappears
You are right - this was indeed the bug!
I am updating the article and checking the react version for the same problem.
Thank you very much!