Courses

React Laravel 12 Starter Kit: CRUD Project

Category ToggleGroup and Tasks Filter

Summary of this lesson:
- Adding categories to task creation and editing forms
- Using Shadcn Toggle Group component for category selection
- Displaying categories in the task list table
- Implementing category filtering for the task list

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@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.tsx

import { type BreadcrumbItem } from '@/types';
import { type BreadcrumbItem, type TaskCategory } from '@/types';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
 
// ...
 
type CreateTaskForm = {
name?: string;
due_date?: string;
media?: string;
categories?: string[];
};
 
export default function Create() {
export default function Create({ categories }: { categories: TaskCategory[] }) {
const taskName = useRef<HTMLInputElement>(null);
 
const { data, setData, errors, post, reset, processing, progress } = useForm<Required<CreateTaskForm>>({
name: '',
due_date: '',
media: '',
categories: [],
});
 
// ...
 
<div className="grid gap-2">
<Label htmlFor="due_date">Categories</Label>
 
<ToggleGroup type="multiple" variant={'outline'} size={'lg'} value={data.categories} onValueChange={(value) => setData('categories', value)}>
{categories.map((category) => (
<ToggleGroupItem key={category.id} value={category.id.toString()}>
{category.name}
</ToggleGroupItem>
))}
</ToggleGroup>
 
<InputError message={errors.due_date} />
</div>
 
// ...

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 React page tasks list: a new <TableHead> and a new <TableCell> with the map() method.

resources/js/Pages/Tasks/Index.tsx

import { type BreadcrumbItem, type PaginatedResponse, type Task } from '@/types';
import { type BreadcrumbItem, type PaginatedResponse, type Task, type TaskCategory } from '@/types';
 
// ...
 
<Table className={'mt-4'}>
<TableHeader>
<TableRow>
// ...
<TableHead className="w-[200px]">Categories</TableHead>
<TableHead className="w-[100px]">Status</TableHead>
// ...
</TableRow>
</TableHeader>
<TableBody>
{tasks.data.map((task: Task) => (
<TableRow key={task.id}>
// ...
<TableCell className={'flex flex-row gap-x-2'}>
{task.task_categories?.map((category: TaskCategory) => (
<span key={category.id} className="rounded-full bg-gray-200 px-2 py-1 text-xs">
{category.name}
</span>
))}
</TableCell>
<TableCell className={task.is_completed ? 'text-green-600' : 'text-red-700'}>
{task.is_completed ? 'Completed' : 'In Progress'}
</TableCell>
// ...
</TableRow>
))}
</TableBody>
</Table>
// ...

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.tsx

import { type BreadcrumbItem, type Task } from '@/types';
import { type BreadcrumbItem, type Task, type TaskCategory } from '@/types';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
 
// ...
 
type EditTaskForm = {
name: string;
is_completed: boolean;
due_date?: string;
media?: string;
categories: string[];
};
 
export default function Edit({ task }: { task: Task; }) {
export default function Edit({ task, categories }: { task: Task, categories: TaskCategory[] }) {
// ...
const { data, setData, errors, reset, processing, progress } = useForm<Required<EditTaskForm>>({
name: task.name,
is_completed: task.is_completed,
due_date: task.due_date,
media: '',
categories: task.task_categories.map((category) => category.id.toString()),
});
 
// ...
 
<div className="grid gap-2">
<Label htmlFor="due_date">Categories</Label>
 
<ToggleGroup type="multiple" variant={'outline'} size={'lg'} value={data.categories}
onValueChange={(value) => setData('categories', value)}>
{categories.map((category) => (
<ToggleGroupItem key={category.id} value={category.id.toString()}>
{category.name}
</ToggleGroupItem>
))}
</ToggleGroup>
 
<InputError message={errors.due_date} />
</div>
 
// ...

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 React 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.tsx

export default function Index({
tasks,
categories,
selectedCategories,
}: {
tasks: PaginatedResponse<Task>,
categories: TaskCategory[],
selectedCategories: string[] | null,
}) {
const deleteTask = (id: number) => {
if (confirm('Are you sure?')) {
router.delete(route('tasks.destroy', { id }));
toast.success('Task deleted successfully');
}
};
 
const selectCategory = (id: string) => {
const selected = selectedCategories?.includes(id)
? selectedCategories?.filter((category) => category !== id)
: [...(selectedCategories || []), id];
router.visit('/tasks', { data: { categories: selected } });
};
 
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Index" />
<div className={'mt-8'}>
<div className={'flex flex-row gap-x-4'}>
<Link className={buttonVariants({ variant: 'default' })} href="/tasks/create">
Create Task
</Link>
<Link className={buttonVariants({ variant: 'outline' })} href="/task-categories">
Manage Task Categories
</Link>
</div>
 
<div className={'mt-4 flex flex-row justify-center gap-x-2'}>
{categories.map((category: TaskCategory) => (
<Button
variant={selectedCategories?.includes(category.id.toString()) ? 'default' : 'outline'}
key={category.id}
onClick={() => selectCategory(category.id.toString())}
>
{category.name} ({category.tasks_count})
</Button>
))}
</div>
 
<Table className={'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 are three GitHub commits for this lesson:

Previous: Practice Another CRUD: Task Categories
avatar
Luis Antonio Parrado

You forget put

'tasks' => Task::query()
    ->with('media', 'taskCategories')   //Missing
    ->when(....)

in the task query

avatar

Thanks for letting us know! I've updated the lesson!

avatar
You can use Markdown
avatar
You can use Markdown