Courses

Vue.js 3 + Laravel 11 API + Vite: SPA CRUD

Extra Table Filters: Column and Global

Summary of this lesson:
- Adding column-specific filtering
- Implementing global search functionality
- Creating dynamic filter queries
- Managing multiple filter states

In this last lesson of the Full CRUD of Posts section, let's add more filters to the posts table. We will add a filter for each column and another search input to search in all columns.

finished table with extra filters


Search in Every Column

Let's start this lesson from the back-end. We need to add more condinional clauses for each column. Also, we will add a prefix search_ to each search variable.

app/Http/Controllers/Api/PostController.php:

use Illuminate\Database\Eloquent\Builder;
 
class PostController extends Controller
{
public function index()
{
$orderColumn = request('order_column', 'created_at');
if (! in_array($orderColumn, ['id', 'title', 'created_at'])) {
$orderColumn = 'created_at';
}
$orderDirection = request('order_direction', 'desc');
if (! in_array($orderDirection, ['asc', 'desc'])) {
$orderDirection = 'desc';
}
 
$posts = Post::with('category')
->when(request('category'), function (Builder $query) {
$query->where('category_id', request('category'));
->when(request('search_category'), function (Builder $query) {
$query->where('category_id', request('search_category'));
})
->when(request('search_id'), function (Builder $query) {
$query->where('id', request('search_id'));
})
->when(request('search_title'), function (Builder $query) {
$query->where('title', 'like', '%' . request('search_title') . '%');
})
->when(request('search_content'), function (Builder $query) {
$query->where('content', 'like', '%' . request('search_content') . '%');
})
->orderBy($orderColumn, $orderDirection)
->paginate(10);
 
return PostResource::collection($posts);
}
// ...
}

Now in the posts Composable, we need to pass those parameters.

resources/js/composables/posts.js:

import { ref, inject } from 'vue'
import { useRouter } from 'vue-router'
 
export default function usePosts() {
const posts = ref({})
const post = ref({})
const router = useRouter()
const validationErrors = ref({})
const isLoading = ref(false)
const swal = inject('$swal')
 
const getPosts = async (
page = 1,
category = '',
search_category = '',
search_id = '',
search_title = '',
search_content = '',
order_column = 'created_at',
order_direction = 'desc'
) => {
axios.get('/api/posts?page=' + page +
'&category=' + category +
'&search_category=' + search_category +
'&search_id=' + search_id +
'&search_title=' + search_title +
'&search_content=' + search_content +
'&order_column=' + order_column +
'&order_direction=' + order_direction)
.then(response => {
posts.value = response.data;
})
}
// ...
}

Next, we need to add the same variables in the PostsIndex Vue component. Also, for the updateOrdering and when watching search_category we need to pass all these parameters to the getPosts method.

And don't forget renaming: the variable category now should be search_category everywhere.

resources/js/components/Posts/Index.vue:

<template>
<div class="overflow-hidden overflow-x-auto p-6 bg-white border-gray-200">
<div class="min-w-full align-middle">
<div class="mb-4">
<select v-model="selectedCategory" class="block mt-1 w-full sm:w-1/4 rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<select v-model="search_category" class="block mt-1 w-full sm:w-1/4 rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<option value="" selected>&#45;&#45; Filter by category &#45;&#45;</option>
<option v-for="category in categories" :value="category.id" :key="category.id">
{{ category.name }}
</option>
</select>
</div>
// ...
<TailwindPagination :data="posts" @pagination-change-page="page => getPosts(page, selectedCategory)" class="mt-4" />
<TailwindPagination :data="posts" @pagination-change-page="page => getPosts(page, search_category)" class="mt-4" />
</div>
</div>
</template>
 
<script setup>
import { onMounted, ref, watch } from "vue";
import { TailwindPagination } from 'laravel-vue-pagination';
import usePosts from "@/composables/posts";
import useCategories from "@/composables/categories";
 
const category = ref('')
const search_category = ref('')
const search_id = ref('')
const search_title = ref('')
const search_content = ref('')
const search_global = ref('')
const orderColumn = ref('created_at')
const orderDirection = ref('desc')
const { posts, getPosts, deletePost } = usePosts()
const { categories, getCategories } = useCategories()
 
const updateOrdering = (column) => {
orderColumn.value = column
orderDirection.value = (orderDirection.value === 'asc') ? 'desc' : 'asc'
getPosts(
1,
search_category.value,
search_id.value,
search_title.value,
search_content.value,
orderColumn.value,
orderDirection.value
);
}
 
onMounted(() => {
getPosts()
getCategories()
})
 
watch(selectedCategory, (current, previous) => {
getPosts(1, current)
})
watch(search_category, (current, previous) => {
getPosts(
1,
current
search_id.value,
search_title.value,
search_content.value
)
})
</script>

Now let's add search inputs for the columns. In the table head, we will add another row.

resources/js/components/Posts/Index.vue:

<template>
<div class="overflow-hidden overflow-x-auto p-6 bg-white border-gray-200">
<div class="min-w-full align-middle">
<div class="mb-4">
<select v-model="selectedCategory" class="block mt-1 w-full sm:w-1/4 rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<option value="" selected>&#45;&#45; Filter by category &#45;&#45;</option>
<option v-for="category in categories" :value="category.id" :key="category.id">
{{ category.name }}
</option>
</select>
</div>
 
<table class="min-w-full divide-y divide-gray-200 border">
<thead>
<tr>
<th class="px-6 py-3 bg-gray-50 text-left">
<input v-model="search_id" type="text" class="inline-block w-full rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" placeholder="Filter by ID">
</th>
<th class="px-6 py-3 bg-gray-50 text-left">
<input v-model="search_title" type="text" class="inline-block w-full rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" placeholder="Filter by Title">
</th>
<th class="px-6 py-3 bg-gray-50 text-left">
<select v-model="search_category" class="inline-block w-full rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<option value="" selected>-- all categories --</option>
<option v-for="category in categories" :value="category.id">
{{ category.name }}
</option>
</select>
</th>
<th class="px-6 py-3 bg-gray-50 text-left">
<input v-model="search_content" type="text" class="inline-block w-full rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" placeholder="Filter by Content">
</th>
<th class="px-6 py-3 bg-gray-50 text-left"></th>
<th class="px-6 py-3 bg-gray-50 text-left"></th>
</tr>
<tr>
// ...
</tr>
</thead>
// ...
</table>
 
<TailwindPagination :data="posts" @pagination-change-page="page => getPosts(page, search_category)" class="mt-4" />
</div>
</div>
</template>
 
<script setup>
// ...
</script>

Now you should see input in the table header, similar to this:

filter inputs

But if you try to search, it wouldn't work yet. The only filter that would work now is the category. This is because we haven't added a watch() for other inputs.

resources/js/components/Posts/Index.vue:

<script setup>
// ...
 
watch(search_category, (current, previous) => {
getPosts(
1,
current,
search_id.value,
search_title.value,
search_content.value
)
})
watch(search_id, (current, previous) => {
getPosts(
1,
search_category.value,
current,
search_title.value,
search_content.value
)
})
watch(search_title, (current, previous) => {
getPosts(
1,
search_category.value,
search_id.value,
current,
search_content.value
)
})
watch(search_content, (current, previous) => {
getPosts(
1,
search_category.value,
search_id.value,
search_title.value,
current
)
})
</script>

The main thing in all of these watches is to pass the current value for the appropriate parameter. Now search should work for all the fields.

working search


Global Search Input

The last thing, let's replace the filter by category above the table with the global search input.

So, first, let's replace this input and add a variable for it. Also, again, we need to add this search_global variable to all the watches.

resources/js/components/Posts/Index.vue:

<template>
<div class="overflow-hidden overflow-x-auto p-6 bg-white border-gray-200">
<div class="min-w-full align-middle">
<div class="mb-4">
<select v-model="search_category" class="block mt-1 w-full sm:w-1/4 rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<option value="" selected>-- Filter by category --</option>
<option v-for="category in categories" :value="category.id" :key="category.id">
{{ category.name }}
</option>
</select>
</div>
<div class="mb-4 grid lg:grid-cols-4">
<input v-model="search_global" type="text" placeholder="Search..." class="inline-block mt-1 w-full rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
</div>
 
// ...
 
<TailwindPagination :data="posts" @pagination-change-page="page => getPosts(page, search_category)" class="mt-4" />
</div>
</div>
</template>
 
<script setup>
import { onMounted, ref, watch } from "vue";
import { TailwindPagination } from 'laravel-vue-pagination';
import usePosts from "@/composables/posts";
import useCategories from "@/composables/categories";
 
const search_category = ref('')
const search_id = ref('')
const search_title = ref('')
const search_content = ref('')
const search_global = ref('')
const orderColumn = ref('created_at')
const orderDirection = ref('desc')
const { posts, getPosts, deletePost } = usePosts()
const { categories, getCategories } = useCategories()
 
// ...
watch(search_category, (current, previous) => {
getPosts(
1,
current,
search_id.value,
search_title.value,
search_content.value,
search_global.value
)
})
watch(search_id, (current, previous) => {
getPosts(
1,
search_category.value,
current,
search_title.value,
search_content.value,
search_global.value
)
})
watch(search_title, (current, previous) => {
getPosts(
1,
search_category.value,
search_id.value,
current,
search_content.value,
search_global.value
)
})
watch(search_content, (current, previous) => {
getPosts(
1,
search_category.value,
search_id.value,
search_title.value,
current,
search_global.value
)
})
watch(search_global, (current, previous) => {
getPosts(
1,
search_category.value,
search_id.value,
search_title.value,
search_content.value,
current
)
})
</script>

Next, in the posts Composable, we need to add it to the getPosts method as we did with other search inputs.

resources/js/composables/posts.js:

import { ref, inject } from 'vue'
import { useRouter } from 'vue-router'
 
export default function usePosts() {
const posts = ref({})
const post = ref({})
const router = useRouter()
const validationErrors = ref({})
const isLoading = ref(false)
const swal = inject('$swal')
 
const getPosts = async (
page = 1,
// category = '',
search_category = '',
search_id = '',
search_title = '',
search_content = '',
search_global = '',
order_column = 'created_at',
order_direction = 'desc'
) => {
axios.get('/api/posts?page=' + page +
'&search_category=' + search_category +
'&search_id=' + search_id +
'&search_title=' + search_title +
'&search_content=' + search_content +
'&search_global=' + search_global +
'&order_column=' + order_column +
'&order_direction=' + order_direction)
.then(response => {
posts.value = response.data;
})
}
// ...
}

And all that's left is to add another when in the PostController. We can use the whereAny to query all the fields.

use Illuminate\Database\Eloquent\Builder;
 
class PostController extends Controller
{
public function index()
{
$orderColumn = request('order_column', 'created_at');
if (! in_array($orderColumn, ['id', 'title', 'created_at'])) {
$orderColumn = 'created_at';
}
$orderDirection = request('order_direction', 'desc');
if (! in_array($orderDirection, ['asc', 'desc'])) {
$orderDirection = 'desc';
}
 
$posts = Post::with('category')
->when(request('search_category'), function (Builder $query) {
$query->where('category_id', request('search_category'));
})
->when(request('search_id'), function (Builder $query) {
$query->where('id', request('search_id'));
})
->when(request('search_title'), function (Builder $query) {
$query->where('title', 'like', '%' . request('search_title') . '%');
})
->when(request('search_content'), function (Builder $query) {
$query->where('content', 'like', '%' . request('search_content') . '%');
})
->when(request('search_global'), function (Builder $query) {
$query->whereAny([
'id',
'title',
'content',
], 'LIKE', '%' . request('search_global') . '%');
})
->orderBy($orderColumn, $orderDirection)
->paginate(10);
 
return PostResource::collection($posts);
}
// ...
}

Now the global search should also be working!

working global search

Previous: Delete Post with Confirmation Modal
avatar

Hi. I found a pagination bug. We added search_category to getPosts: <TailwindPagination :data="posts" @pagination-change-page="page => getPosts(page, search_category)" class="mt-4"/> and it works when we filtering by some category with many posts.

But it doesnt work when we filtering by title or content or global. I posted many posts with words "test" and a pagination shows me incorect numbers of pages when I filtering by a "test" word. How to fix that?

avatar

Add every value to the getPosts in the pagination and it should work.

<TailwindPagination :data="posts" @pagination-change-page="page => getPosts(page, search_category, search_id, search_title, search_content, search_global, orderColumn, orderDirection)" class="mt-4" />
avatar

Thanks. It works now. I don't understand how, but I tried this way before asked the question and it didn't work... Magic =)

avatar

@Yuri thanks for that man, I was going crazy thinking i was the only one who noticed this!

avatar
You can use Markdown
avatar

I followed every step in this course but when I use search I got wrong result need help plz

avatar

I try with this query "$posts = Post::with('project')->where('category_id', '=' , request('search_category')) " and it works but I need multi filters

avatar

Sorry, but in the comments it's impossible to help you.

avatar

$posts = Post::with('project')->where('category_id', '=' , request('search_category'))

avatar

what should I do? I am new in this website

avatar

it works when re-arrange the inside getPosts(...) can you explain me watch(search_id, (current, previous) => { getPosts( 1, search_category.value, current, search_title.value, search_content.value ) }) watch(search_title, (current, previous) => { getPosts( 1, search_category.value, search_id.value, current, search_content.value ) }) watch(search_content, (current, previous) => { getPosts( 1, search_category.value, search_id.value, search_title.value, current ) })

avatar

You have two options:

  1. Go to laravel daily discord and ask there.
  2. Go to forums like laracasts

In both options when showing code format it so it would be readable using markdown. Use three backtick and language name like ``` php

avatar

Thanks :)

avatar
You can use Markdown
avatar
You can use Markdown