Table with sorting, pagination, text search.
public function index(Request $request){
$query = User::query();
if ($request->has('search')) {
$search = $request->input('search');
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($request->has('sort')) {
$sortBy = $request->input('sort');
$direction = $request->input('direction', 'asc');
$allowedColumns = ['id', 'name', 'email', 'role'];
if (in_array($sortBy, $allowedColumns)) {
$query->orderBy($sortBy, $direction);
}
} else {
$query->latest('id');
}
$perPage = $request->input('per_page', 10);
$users = $query->paginate($perPage)->withQueryString();
return Inertia::render('admin/users/table/Index', [
'users' => $users,
]);
}admin/users/table/Index.vue
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { router } from '@inertiajs/vue3'
import {
useTable,
tableFeatures,
columnVisibilityFeature,
rowSortingFeature,
createColumnHelper,
FlexRender,
type Updater,
type SortingState,
type PaginationState,
} from '@tanstack/vue-table'
type User = {
id: number
name: string
email: string
role: string
}
type LaravelPagination = {
data: User[]
current_page: number
last_page: number
per_page: number
total: number
}
const props = defineProps<{
users: LaravelPagination
}>()
const urlParams = new URLSearchParams(window.location.search)
// 1. Stan dla wyszukiwarki (pobiera wartość z URL przy F5)
const searchQuery = ref(urlParams.get('search') ?? '')
const debouncedSearch = ref(searchQuery.value)
const sorting = ref<SortingState>(
urlParams.get('sort')
? [{ id: urlParams.get('sort') as string, desc: urlParams.get('direction') === 'desc' }]
: [],
)
const pagination = ref<PaginationState>({
pageIndex: urlParams.get('page') ? Number(urlParams.get('page')) - 1 : 0,
pageSize: urlParams.get('per_page') ? Number(urlParams.get('per_page')) : 10,
})
// Prosty mechanizm debouncingu dla inputu wyszukiwania
let timeoutId: ReturnType<typeof setTimeout>
watch(searchQuery, (newVal) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
debouncedSearch.value = newVal
pagination.value.pageIndex = 0 // Reset do 1. strony, gdy użytkownik zmienia frazę
}, 350) // opóźnienie 350ms przed wysłaniem zapytania
})
// 3. Synchronizacja z paskiem adresu URL (uwzględnia debouncedSearch)
watch(
[sorting, pagination, debouncedSearch],
([newSorting, newPagination, newSearch]) => {
const query: Record<string, string | number> = {
page: newPagination.pageIndex + 1,
per_page: newPagination.pageSize,
}
if (newSorting.length > 0) {
const firstSort = newSorting[0]
query.sort = firstSort.id
query.direction = firstSort.desc ? 'desc' : 'asc'
}
// Zawsze przekazujemy search, nawet pusty string, żeby wyczyścić filtry w Laravelu
query.search = newSearch
// Prawidłowy zapis opcji dla nowej wersji Inertii
router.reload({
data: query,
only: ['users'], // Odświeża tylko dane tabeli (Partial Reload)
})
},
{ deep: true },
)
const tableData = computed(() => props.users.data)
const pageCount = computed(() => props.users.last_page)
const features = tableFeatures({
columnVisibilityFeature,
rowSortingFeature,
})
const columnHelper = createColumnHelper<typeof features, User>()
const columns = columnHelper.columns([
columnHelper.accessor('id', { header: 'ID' }),
columnHelper.accessor('name', { header: 'Imię i nazwisko' }),
columnHelper.accessor('email', { header: 'Adres E-mail' }),
columnHelper.accessor('role', { header: 'Rola' }),
])
const table = useTable({
features,
get data() {
return tableData.value
},
columns,
manualSorting: true,
state: {
get sorting() {
return sorting.value
},
},
onSortingChange: (updater: Updater<SortingState>) => {
sorting.value = typeof updater === 'function' ? updater(sorting.value) : updater
pagination.value.pageIndex = 0
},
})
</script>
<template>
<div style="padding: 20px; font-family: sans-serif">
<div style="margin-bottom: 15px">
<input
v-model="searchQuery"
type="text"
placeholder="Szukaj użytkownika..."
style="padding: 8px 12px; width: 300px; border: 1px solid #ccc; border-radius: 4px"
/>
</div>
<table style="width: 100%; border-collapse: collapse; text-align: left">
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
style="border-bottom: 2px solid #ccc"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
style="padding: 10px; user-select: none"
>
<div
v-if="!header.isPlaceholder"
:style="
header.column.getCanSort()
? 'cursor: pointer; display: flex; gap: 8px;'
: ''
"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span v-if="header.column.getIsSorted() === 'asc'">🔼</span>
<span v-else-if="header.column.getIsSorted() === 'desc'">🔽</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in table.getRowModel().rows"
:key="row.id"
style="border-bottom: 1px solid #eee"
>
<td v-for="cell in row.getVisibleCells()" :key="cell.id" style="padding: 10px">
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
<!-- Kontrolki paginacji dolnej -->
<div style="margin-top: 15px; display: flex; align-items: center; gap: 10px">
<button
:disabled="pagination.pageIndex === 0"
@click="pagination.pageIndex--"
style="padding: 5px 10px; cursor: pointer"
>
Poprzednia
</button>
<span>
Strona <strong>{{ pagination.pageIndex + 1 }}</strong> z
<strong>{{ pageCount }}</strong>
</span>
<button
:disabled="pagination.pageIndex + 1 >= pageCount"
@click="pagination.pageIndex++"
style="padding: 5px 10px; cursor: pointer"
>
Następna
</button>
<select
:value="pagination.pageSize"
@change="
((pagination.pageSize = Number(($event.target as HTMLSelectElement).value)),
(pagination.pageIndex = 0))
"
style="padding: 5px"
>
<option v-for="size in [1, 5, 10, 25, 50]" :key="size" :value="size">
Pokaż {{ size }}
</option>
</select>
</div>
</div>
</template>