Add server-side filtering with AJAX pagination for users

This commit is contained in:
anomny
2025-11-05 19:45:31 +01:00
parent 981d3614b9
commit cd0173a38c
2 changed files with 282 additions and 60 deletions

View File

@@ -75,7 +75,14 @@ public class HomeController : Controller
[Authorize(Roles = "CanManageUsers")] [Authorize(Roles = "CanManageUsers")]
[HttpGet("Users")] [HttpGet("Users")]
public async Task<ActionResult> UsersAsync([FromQuery] int page = 1, [FromQuery] int pageSize = 50) public async Task<ActionResult> UsersAsync(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? username = null,
[FromQuery] string? title = null,
[FromQuery] string? name = null,
[FromQuery] string? surname = null,
[FromQuery] string? workplace = null)
{ {
page = Math.Max(1, page); page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100); pageSize = Math.Clamp(pageSize, 10, 100);
@@ -83,6 +90,29 @@ public class HomeController : Controller
// Fetch all users with jpegPhoto (but not userPassword) // Fetch all users with jpegPhoto (but not userPassword)
IEnumerable<UserModel> allUsers = await _ldap.ListUsersAsync([.. _ldap.UsersAttributes.Where(attr => attr != "userPassword")]); IEnumerable<UserModel> allUsers = await _ldap.ListUsersAsync([.. _ldap.UsersAttributes.Where(attr => attr != "userPassword")]);
List<UserModel> usersList = allUsers.ToList(); List<UserModel> usersList = allUsers.ToList();
// Apply filters
if (!string.IsNullOrWhiteSpace(username))
{
usersList = usersList.Where(u => u.Uid?.Contains(username, StringComparison.OrdinalIgnoreCase) == true).ToList();
}
if (!string.IsNullOrWhiteSpace(title))
{
usersList = usersList.Where(u => u.Title?.Contains(title, StringComparison.OrdinalIgnoreCase) == true).ToList();
}
if (!string.IsNullOrWhiteSpace(name))
{
usersList = usersList.Where(u => u.Cn?.Contains(name, StringComparison.OrdinalIgnoreCase) == true).ToList();
}
if (!string.IsNullOrWhiteSpace(surname))
{
usersList = usersList.Where(u => u.Sn?.Contains(surname, StringComparison.OrdinalIgnoreCase) == true).ToList();
}
if (!string.IsNullOrWhiteSpace(workplace))
{
usersList = usersList.Where(u => u.Description?.Workplace?.Contains(workplace, StringComparison.OrdinalIgnoreCase) == true).ToList();
}
int totalUsers = usersList.Count; int totalUsers = usersList.Count;
List<UserModel> paginatedUsers = usersList List<UserModel> paginatedUsers = usersList

View File

@@ -19,7 +19,7 @@
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createModal"> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createModal">
@T["Create user"] @T["Create user"]
</button> </button>
<div class="text-muted"> <div class="text-muted" id="userCountInfo">
<strong>@Model.TotalUsers</strong> @T["users"] @T["total"] <strong>@Model.TotalUsers</strong> @T["users"] @T["total"]
@if (Model.TotalPages > 1) @if (Model.TotalPages > 1)
{ {
@@ -28,8 +28,40 @@
</div> </div>
</div> </div>
@* Filter Section *@
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title">@T["Filter Users"]</h5>
<div class="row g-3">
<div class="col-md-2">
<input type="text" id="filterUsername" class="form-control form-control-sm" placeholder="@T["Username"]" />
</div>
<div class="col-md-2">
<input type="text" id="filterTitle" class="form-control form-control-sm" placeholder="@T["Title"]" />
</div>
<div class="col-md-2">
<input type="text" id="filterName" class="form-control form-control-sm" placeholder="@T["Name"]" />
</div>
<div class="col-md-2">
<input type="text" id="filterSurname" class="form-control form-control-sm" placeholder="@T["Surname"]" />
</div>
<div class="col-md-2">
<input type="text" id="filterWorkplace" class="form-control form-control-sm" placeholder="@T["Workplace"]" />
</div>
<div class="col-md-2">
<button id="clearFilters" class="btn btn-sm btn-secondary w-100">@T["Clear Filters"]</button>
</div>
</div>
</div>
</div>
<div class="table-responsive"> <div id="loadingSpinner" class="text-center my-4" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div class="table-responsive" id="userTableContainer">
<table class="table table-striped align-middle"> <table class="table table-striped align-middle">
<thead> <thead>
<tr> <tr>
@@ -42,7 +74,7 @@
<th>@T["Action"]</th> <th>@T["Action"]</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="userTableBody">
@{ @{
foreach (UserTableViewModel userTableViewModel in Model.UserTableViewModels) foreach (UserTableViewModel userTableViewModel in Model.UserTableViewModels)
{ {
@@ -97,64 +129,224 @@
</div> </div>
@* Pagination Controls *@ @* Pagination Controls *@
@if (Model.TotalPages > 1) <nav aria-label="User pagination" class="mt-4" id="paginationContainer">
{ <div class="d-flex justify-content-between align-items-center">
<nav aria-label="User pagination" class="mt-4"> <div class="text-muted" id="paginationInfo">
<div class="d-flex justify-content-between align-items-center"> @T["Showing"] @((Model.CurrentPage - 1) * Model.PageSize + 1) - @Math.Min(Model.CurrentPage * Model.PageSize, Model.TotalUsers) @T["of"] @Model.TotalUsers @T["users"]
<div class="text-muted">
@T["Showing"] @((Model.CurrentPage - 1) * Model.PageSize + 1) - @Math.Min(Model.CurrentPage * Model.PageSize, Model.TotalUsers) @T["of"] @Model.TotalUsers @T["users"]
</div>
<ul class="pagination mb-0">
@* Previous Button *@
<li class="page-item @(Model.CurrentPage == 1 ? "disabled" : "")">
<a class="page-link" href="?page=@(Model.CurrentPage - 1)&pageSize=@Model.PageSize" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
@* Page Numbers *@
@{
int startPage = Math.Max(1, Model.CurrentPage - 2);
int endPage = Math.Min(Model.TotalPages, Model.CurrentPage + 2);
if (startPage > 1)
{
<li class="page-item"><a class="page-link" href="?page=1&pageSize=@Model.PageSize">1</a></li>
if (startPage > 2)
{
<li class="page-item disabled"><span class="page-link">...</span></li>
}
}
for (int i = startPage; i <= endPage; i++)
{
<li class="page-item @(i == Model.CurrentPage ? "active" : "")">
<a class="page-link" href="?page=@i&pageSize=@Model.PageSize">@i</a>
</li>
}
if (endPage < Model.TotalPages)
{
if (endPage < Model.TotalPages - 1)
{
<li class="page-item disabled"><span class="page-link">...</span></li>
}
<li class="page-item"><a class="page-link" href="?page=@Model.TotalPages&pageSize=@Model.PageSize">@Model.TotalPages</a></li>
}
}
@* Next Button *@
<li class="page-item @(Model.CurrentPage >= Model.TotalPages ? "disabled" : "")">
<a class="page-link" href="?page=@(Model.CurrentPage + 1)&pageSize=@Model.PageSize" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</div> </div>
</nav> <ul class="pagination mb-0" id="paginationButtons">
} @* Pagination buttons will be generated dynamically via JavaScript *@
</ul>
</div>
</nav>
</div> </div>
<script>
// Global state for pagination and filtering
let currentPage = @Model.CurrentPage;
let pageSize = @Model.PageSize;
let totalUsers = @Model.TotalUsers;
let totalPages = @Model.TotalPages;
let currentFilters = {
username: '',
title: '',
name: '',
surname: '',
workplace: ''
};
document.addEventListener('DOMContentLoaded', () => {
// Initialize pagination
renderPaginationButtons();
// Setup filter inputs with debounce
const filterInputs = {
username: document.getElementById('filterUsername'),
title: document.getElementById('filterTitle'),
name: document.getElementById('filterName'),
surname: document.getElementById('filterSurname'),
workplace: document.getElementById('filterWorkplace')
};
let debounceTimer;
Object.keys(filterInputs).forEach(key => {
filterInputs[key].addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
currentFilters[key] = e.target.value;
currentPage = 1; // Reset to first page on filter change
loadUsers();
}, 500); // Wait 500ms after user stops typing
});
});
// Clear filters button
document.getElementById('clearFilters').addEventListener('click', () => {
Object.keys(filterInputs).forEach(key => {
filterInputs[key].value = '';
currentFilters[key] = '';
});
currentPage = 1;
loadUsers();
});
});
async function loadUsers() {
const spinner = document.getElementById('loadingSpinner');
const tableContainer = document.getElementById('userTableContainer');
// Show spinner, hide table
spinner.style.display = 'block';
tableContainer.style.opacity = '0.5';
// Build query string
const params = new URLSearchParams({
page: currentPage,
pageSize: pageSize
});
Object.keys(currentFilters).forEach(key => {
if (currentFilters[key]) {
params.append(key, currentFilters[key]);
}
});
try {
const response = await fetch(`/Home/Users?${params.toString()}`, {
headers: {
'Accept': 'text/html'
}
});
const html = await response.text();
// Parse the response HTML to extract table rows and pagination info
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Extract table body
const newTableBody = doc.querySelector('#userTableBody');
if (newTableBody) {
document.getElementById('userTableBody').innerHTML = newTableBody.innerHTML;
// Re-register row click handlers
document.querySelectorAll('#userTableBody tr').forEach(row => {
registerRowDetailviewClick(row);
});
}
// Extract pagination info from the response
const userCountInfo = doc.querySelector('#userCountInfo');
if (userCountInfo) {
const totalUsersMatch = userCountInfo.textContent.match(/(\d+)\s+users/i);
if (totalUsersMatch) {
totalUsers = parseInt(totalUsersMatch[1]);
totalPages = Math.ceil(totalUsers / pageSize);
// Update count info
document.getElementById('userCountInfo').innerHTML = userCountInfo.innerHTML;
}
}
// Update pagination info text
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, totalUsers);
document.getElementById('paginationInfo').textContent =
`@T["Showing"] ${start} - ${end} @T["of"] ${totalUsers} @T["users"]`;
// Render pagination buttons
renderPaginationButtons();
} catch (error) {
console.error('Error loading users:', error);
showToast('@T["Error loading users"]', 'danger');
} finally {
// Hide spinner, restore table
spinner.style.display = 'none';
tableContainer.style.opacity = '1';
}
}
function renderPaginationButtons() {
const container = document.getElementById('paginationButtons');
if (totalPages <= 1) {
document.getElementById('paginationContainer').style.display = 'none';
return;
}
document.getElementById('paginationContainer').style.display = 'block';
container.innerHTML = '';
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" aria-label="Previous"><span aria-hidden="true">&laquo;</span></a>`;
if (currentPage > 1) {
prevLi.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
currentPage--;
loadUsers();
});
}
container.appendChild(prevLi);
// Page numbers
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
if (startPage > 1) {
appendPageButton(container, 1);
if (startPage > 2) {
const ellipsis = document.createElement('li');
ellipsis.className = 'page-item disabled';
ellipsis.innerHTML = '<span class="page-link">...</span>';
container.appendChild(ellipsis);
}
}
for (let i = startPage; i <= endPage; i++) {
appendPageButton(container, i);
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
const ellipsis = document.createElement('li');
ellipsis.className = 'page-item disabled';
ellipsis.innerHTML = '<span class="page-link">...</span>';
container.appendChild(ellipsis);
}
appendPageButton(container, totalPages);
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${currentPage >= totalPages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" aria-label="Next"><span aria-hidden="true">&raquo;</span></a>`;
if (currentPage < totalPages) {
nextLi.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
currentPage++;
loadUsers();
});
}
container.appendChild(nextLi);
}
function appendPageButton(container, pageNum) {
const li = document.createElement('li');
li.className = `page-item ${pageNum === currentPage ? 'active' : ''}`;
li.innerHTML = `<a class="page-link" href="#">${pageNum}</a>`;
if (pageNum !== currentPage) {
li.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
currentPage = pageNum;
loadUsers();
});
}
container.appendChild(li);
}
</script>
<!-- User Delete Confirmation Modal --> <!-- User Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true"> <div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">