Enhance Books management; add filtering, sorting, and pagination capabilities to BooksController and BooksService, update UI in Books.razor with MudDataGrid integration, and improve data retrieval logic for better performance.

This commit is contained in:
2026-05-17 11:52:19 +01:00
parent 9a55d550cd
commit 92be13dafb
7 changed files with 404 additions and 66 deletions

View File

@@ -90,7 +90,7 @@ namespace Marechai.ApiClient.Books
/// </summary>
/// <param name="pathParameters">Path parameters for the request</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public BooksRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/books{?skip*,take*}", pathParameters)
public BooksRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/books{?filters*,skip*,sortBy*,sortDescending*,take*}", pathParameters)
{
}
/// <summary>
@@ -98,7 +98,7 @@ namespace Marechai.ApiClient.Books
/// </summary>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public BooksRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/books{?skip*,take*}", rawUrl)
public BooksRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/books{?filters*,skip*,sortBy*,sortDescending*,take*}", rawUrl)
{
}
/// <returns>A List&lt;global::Marechai.ApiClient.Models.BookDto&gt;</returns>
@@ -195,8 +195,28 @@ namespace Marechai.ApiClient.Books
public partial class BooksRequestBuilderGetQueryParameters
#pragma warning restore CS1591
{
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
[QueryParameter("filters")]
public string[]? Filters { get; set; }
#nullable restore
#else
[QueryParameter("filters")]
public string[] Filters { get; set; }
#endif
[QueryParameter("skip")]
public int? Skip { get; set; }
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
[QueryParameter("sortBy")]
public string? SortBy { get; set; }
#nullable restore
#else
[QueryParameter("sortBy")]
public string SortBy { get; set; }
#endif
[QueryParameter("sortDescending")]
public bool? SortDescending { get; set; }
[QueryParameter("take")]
public int? Take { get; set; }
}

View File

@@ -22,7 +22,7 @@ namespace Marechai.ApiClient.Books.Count
/// </summary>
/// <param name="pathParameters">Path parameters for the request</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public CountRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/books/count", pathParameters)
public CountRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/books/count{?filters*}", pathParameters)
{
}
/// <summary>
@@ -30,7 +30,7 @@ namespace Marechai.ApiClient.Books.Count
/// </summary>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public CountRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/books/count", rawUrl)
public CountRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/books/count{?filters*}", rawUrl)
{
}
/// <returns>A <see cref="int"/></returns>
@@ -39,11 +39,11 @@ namespace Marechai.ApiClient.Books.Count
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 400 status code</exception>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public async Task<int?> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
public async Task<int?> GetAsync(Action<RequestConfiguration<global::Marechai.ApiClient.Books.Count.CountRequestBuilder.CountRequestBuilderGetQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
{
#nullable restore
#else
public async Task<int?> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
public async Task<int?> GetAsync(Action<RequestConfiguration<global::Marechai.ApiClient.Books.Count.CountRequestBuilder.CountRequestBuilderGetQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
{
#endif
var requestInfo = ToGetRequestInformation(requestConfiguration);
@@ -57,11 +57,11 @@ namespace Marechai.ApiClient.Books.Count
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<global::Marechai.ApiClient.Books.Count.CountRequestBuilder.CountRequestBuilderGetQueryParameters>>? requestConfiguration = default)
{
#nullable restore
#else
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<global::Marechai.ApiClient.Books.Count.CountRequestBuilder.CountRequestBuilderGetQueryParameters>> requestConfiguration = default)
{
#endif
var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters);
@@ -78,12 +78,27 @@ namespace Marechai.ApiClient.Books.Count
{
return new global::Marechai.ApiClient.Books.Count.CountRequestBuilder(rawUrl, RequestAdapter);
}
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
#pragma warning disable CS1591
public partial class CountRequestBuilderGetQueryParameters
#pragma warning restore CS1591
{
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
[QueryParameter("filters")]
public string[]? Filters { get; set; }
#nullable restore
#else
[QueryParameter("filters")]
public string[] Filters { get; set; }
#endif
}
/// <summary>
/// Configuration for the request such as headers, query parameters, and middleware options.
/// </summary>
[Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")]
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class CountRequestBuilderGetRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
public partial class CountRequestBuilderGetRequestConfiguration : RequestConfiguration<global::Marechai.ApiClient.Books.Count.CountRequestBuilder.CountRequestBuilderGetQueryParameters>
{
}
}

View File

@@ -1,5 +1,5 @@
{
"descriptionHash": "39187644E5EA6F6455F9D2812652A572265D56A88F88621125F1ED8A8E6635971826C01E1AE8433383B27AB82E235F5741021EB6D25A2E44E3CAFE011B8DAA4D",
"descriptionHash": "001FF5857D8AF75D32CA7A7B73390EED7EB388BD199A32E13EE496AF1FE7FD989B93DEBF12A7208B67DC427E3F9E52A029A8FFB60FC4E9563807A0D888DCD263",
"descriptionLocation": "../../../../../tmp/openapi.json",
"lockFileVersion": "1.0.0",
"kiotaVersion": "1.31.1",

View File

@@ -25,6 +25,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Claims;
@@ -62,7 +63,9 @@ public class BooksController(
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public Task<int> GetBooksCountAsync() => context.Books.CountAsync();
public Task<int> GetBooksCountAsync([FromQuery(Name = "filters")] string[] filters = null,
CancellationToken cancellationToken = default) =>
ApplyFilters(context.Books.AsNoTracking(), filters).CountAsync(cancellationToken);
[HttpGet("minimum-year")]
[AllowAnonymous]
@@ -220,37 +223,239 @@ public class BooksController(
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public Task<List<BookDto>> GetAsync([FromQuery] int? skip = null, [FromQuery] int? take = null,
public Task<List<BookDto>> GetAsync([FromQuery] int? skip = null,
[FromQuery] int? take = null,
[FromQuery] string sortBy = null,
[FromQuery] bool sortDescending = false,
[FromQuery(Name = "filters")] string[] filters = null,
CancellationToken cancellationToken = default)
{
IQueryable<Book> ordered = context.Books
.OrderBy(b => MarechaiContext.NaturalSortKey(b.SortTitle))
.ThenBy(b => b.Published)
.ThenBy(b => MarechaiContext.NaturalSortKey(b.Title));
IQueryable<Book> query = ApplyFilters(context.Books.AsNoTracking(), filters);
if(skip.HasValue) ordered = ordered.Skip(skip.Value);
if(take.HasValue) ordered = ordered.Take(take.Value);
// When no user-supplied sort is set, keep the legacy default ordering
// (SortTitle → Published → Title) so anonymous unfiltered consumers
// (Marechai.App, public /book pages) see the same order they get today.
IOrderedQueryable<Book> ordered = sortBy switch
{
"Title" => sortDescending
? query.OrderByDescending(b => MarechaiContext.NaturalSortKey(b.Title))
: query.OrderBy(b => MarechaiContext.NaturalSortKey(b.Title)),
"Isbn" => sortDescending
? query.OrderByDescending(b => b.Isbn)
: query.OrderBy(b => b.Isbn),
"Edition" => sortDescending
? query.OrderByDescending(b => b.Edition)
: query.OrderBy(b => b.Edition),
"Pages" => sortDescending
? query.OrderByDescending(b => b.Pages)
: query.OrderBy(b => b.Pages),
"Published" => sortDescending
? query.OrderByDescending(b => b.Published)
: query.OrderBy(b => b.Published),
"Country" => sortDescending
? query.OrderByDescending(b => MarechaiContext.NaturalSortKey(b.Country.Name))
: query.OrderBy(b => MarechaiContext.NaturalSortKey(b.Country.Name)),
"InternetArchiveUrl" => sortDescending
? query.OrderByDescending(b => b.InternetArchiveUrl)
: query.OrderBy(b => b.InternetArchiveUrl),
_ => query.OrderBy(b => MarechaiContext.NaturalSortKey(b.SortTitle))
.ThenBy(b => b.Published)
.ThenBy(b => MarechaiContext.NaturalSortKey(b.Title))
};
return ordered.Select(b => new BookDto
{
Id = b.Id,
Title = b.Title,
NativeTitle = b.NativeTitle,
SortTitle = b.SortTitle,
Published = b.Published,
PublishedPrecision = b.PublishedPrecision,
Isbn = b.Isbn,
CountryId = b.CountryId,
Pages = b.Pages,
Edition = b.Edition,
PreviousId = b.PreviousId,
SourceId = b.SourceId,
Country = b.Country.Name,
CoverGuid = b.CoverGuid,
OriginalCoverExtension = b.OriginalCoverExtension,
InternetArchiveUrl = b.InternetArchiveUrl
})
.ToListAsync(cancellationToken);
IQueryable<Book> paged = ordered;
if(skip.HasValue) paged = paged.Skip(skip.Value);
if(take.HasValue) paged = paged.Take(take.Value);
return paged.Select(b => new BookDto
{
Id = b.Id,
Title = b.Title,
NativeTitle = b.NativeTitle,
SortTitle = b.SortTitle,
Published = b.Published,
PublishedPrecision = b.PublishedPrecision,
Isbn = b.Isbn,
CountryId = b.CountryId,
Pages = b.Pages,
Edition = b.Edition,
PreviousId = b.PreviousId,
SourceId = b.SourceId,
Country = b.Country.Name,
CoverGuid = b.CoverGuid,
OriginalCoverExtension = b.OriginalCoverExtension,
InternetArchiveUrl = b.InternetArchiveUrl
})
.ToListAsync(cancellationToken);
}
/// <summary>
/// Translates MudDataGrid <c>FilterDefinition</c>s wired over the network as
/// <c>"{Column}||{Operator}||{Value}"</c> triples into LINQ predicates against
/// the <see cref="Book"/> entity. Recognized columns mirror the
/// <c>PropertyColumn</c> names emitted by the admin grid: <c>Title</c>,
/// <c>Isbn</c>, <c>Edition</c>, <c>Pages</c>, <c>Published</c>, <c>Country</c>,
/// <c>InternetArchiveUrl</c>. Unknown columns and operators are silently
/// ignored — filters MudBlazor may emit for non-existent columns (or future
/// ones) must never 400 the listing call.
/// </summary>
static IQueryable<Book> ApplyFilters(IQueryable<Book> query, string[] filters)
{
if(filters is null || filters.Length == 0) return query;
foreach(string raw in filters)
{
if(string.IsNullOrWhiteSpace(raw)) continue;
string[] parts = raw.Split("||", 3, StringSplitOptions.None);
if(parts.Length < 2) continue;
string column = parts[0];
string op = parts[1];
string value = parts.Length >= 3 ? parts[2] : string.Empty;
bool isEmpty = op == "is empty";
bool isNotEmp = op == "is not empty";
// Skip non-empty-check operators with no value supplied so a stray
// open-but-unfilled filter UI doesn't accidentally hide every row.
if(!isEmpty && !isNotEmp && string.IsNullOrEmpty(value)) continue;
switch(column)
{
case "Title":
query = op switch
{
"contains" => query.Where(b => b.Title.Contains(value)),
"not contains" => query.Where(b => !b.Title.Contains(value)),
"equals" => query.Where(b => b.Title == value),
"not equals" => query.Where(b => b.Title != value),
"starts with" => query.Where(b => b.Title.StartsWith(value)),
"ends with" => query.Where(b => b.Title.EndsWith(value)),
"is empty" => query.Where(b => b.Title == null || b.Title == string.Empty),
"is not empty" => query.Where(b => b.Title != null && b.Title != string.Empty),
_ => query
};
break;
case "Isbn":
query = op switch
{
"contains" => query.Where(b => b.Isbn != null && b.Isbn.Contains(value)),
"not contains" => query.Where(b => b.Isbn == null || !b.Isbn.Contains(value)),
"equals" => query.Where(b => b.Isbn == value),
"not equals" => query.Where(b => b.Isbn != value),
"starts with" => query.Where(b => b.Isbn != null && b.Isbn.StartsWith(value)),
"ends with" => query.Where(b => b.Isbn != null && b.Isbn.EndsWith(value)),
"is empty" => query.Where(b => b.Isbn == null || b.Isbn == string.Empty),
"is not empty" => query.Where(b => b.Isbn != null && b.Isbn != string.Empty),
_ => query
};
break;
case "Country":
query = op switch
{
"contains" => query.Where(b => b.Country.Name.Contains(value)),
"not contains" => query.Where(b => !b.Country.Name.Contains(value)),
"equals" => query.Where(b => b.Country.Name == value),
"not equals" => query.Where(b => b.Country.Name != value),
"starts with" => query.Where(b => b.Country.Name.StartsWith(value)),
"ends with" => query.Where(b => b.Country.Name.EndsWith(value)),
"is empty" => query.Where(b => b.CountryId == null ||
b.Country.Name == null ||
b.Country.Name == string.Empty),
"is not empty" => query.Where(b => b.CountryId != null &&
b.Country.Name != null &&
b.Country.Name != string.Empty),
_ => query
};
break;
case "InternetArchiveUrl":
query = op switch
{
"contains" => query.Where(b => b.InternetArchiveUrl != null &&
b.InternetArchiveUrl.Contains(value)),
"not contains" => query.Where(b => b.InternetArchiveUrl == null ||
!b.InternetArchiveUrl.Contains(value)),
"equals" => query.Where(b => b.InternetArchiveUrl == value),
"not equals" => query.Where(b => b.InternetArchiveUrl != value),
"starts with" => query.Where(b => b.InternetArchiveUrl != null &&
b.InternetArchiveUrl.StartsWith(value)),
"ends with" => query.Where(b => b.InternetArchiveUrl != null &&
b.InternetArchiveUrl.EndsWith(value)),
"is empty" => query.Where(b => b.InternetArchiveUrl == null ||
b.InternetArchiveUrl == string.Empty),
"is not empty" => query.Where(b => b.InternetArchiveUrl != null &&
b.InternetArchiveUrl != string.Empty),
_ => query
};
break;
case "Edition":
if(op == "is empty") { query = query.Where(b => b.Edition == null); break; }
if(op == "is not empty"){ query = query.Where(b => b.Edition != null); break; }
if(!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int edition))
continue;
query = op switch
{
"=" => query.Where(b => b.Edition == edition),
"!=" => query.Where(b => b.Edition != edition),
">" => query.Where(b => b.Edition > edition),
"<" => query.Where(b => b.Edition < edition),
">=" => query.Where(b => b.Edition >= edition),
"<=" => query.Where(b => b.Edition <= edition),
_ => query
};
break;
case "Pages":
if(op == "is empty") { query = query.Where(b => b.Pages == null); break; }
if(op == "is not empty"){ query = query.Where(b => b.Pages != null); break; }
if(!short.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out short pages))
continue;
query = op switch
{
"=" => query.Where(b => b.Pages == pages),
"!=" => query.Where(b => b.Pages != pages),
">" => query.Where(b => b.Pages > pages),
"<" => query.Where(b => b.Pages < pages),
">=" => query.Where(b => b.Pages >= pages),
"<=" => query.Where(b => b.Pages <= pages),
_ => query
};
break;
case "Published":
if(op == "is empty") { query = query.Where(b => b.Published == null); break; }
if(op == "is not empty"){ query = query.Where(b => b.Published != null); break; }
if(!DateTime.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out DateTime parsed))
continue;
DateTime day = parsed.Date;
query = op switch
{
"is" => query.Where(b => b.Published.HasValue && b.Published.Value.Date == day),
"is not" => query.Where(b => b.Published.HasValue && b.Published.Value.Date != day),
"is after" => query.Where(b => b.Published.HasValue && b.Published.Value.Date > day),
"is before" => query.Where(b => b.Published.HasValue && b.Published.Value.Date < day),
"is on or after" => query.Where(b => b.Published.HasValue && b.Published.Value.Date >= day),
"is on or before" => query.Where(b => b.Published.HasValue && b.Published.Value.Date <= day),
_ => query
};
break;
}
}
return query;
}
[HttpGet("{id:long}")]

View File

@@ -24,23 +24,15 @@
<MudAlert Severity="Severity.Success" Class="mb-4" ShowCloseIcon="true" CloseIconClicked="() => _successMessage = null">@_successMessage</MudAlert>
}
@if(_isLoading)
{
<div class="d-flex justify-center pa-8">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large"/>
</div>
return;
}
<MudPaper Elevation="2" Class="rounded-lg">
<MudToolBar Dense="true">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="OpenAddBookDialog">@L["Add Book"]</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Upload" OnClick="OpenImportDialog" Class="ml-2">@L["Import CSV"]</MudButton>
<MudSpacer/>
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadBooksAsync"/>
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="async () => await _dataGrid.ReloadServerData()"/>
</MudToolBar>
<MudDataGrid Items="_books" Dense="true" Hover="true" Striped="true" Filterable="true">
<MudDataGrid @ref="_dataGrid" T="BookDto" ServerData="ServerReload" Dense="true" Hover="true" Striped="true" Filterable="true" RowsPerPage="25">
<Columns>
<PropertyColumn Property="x => x.Title" Title="@L["Title"]"/>
<PropertyColumn Property="x => x.Isbn" Title="@L["ISBN"]"/>
@@ -52,7 +44,7 @@
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.Country" Title="@L["Country"]"/>
<TemplateColumn Title="@L["Internet Archive link"]" Sortable="false" Filterable="false">
<PropertyColumn Property="x => x.InternetArchiveUrl" Title="@L["Internet Archive link"]">
<CellTemplate>
@if(!string.IsNullOrWhiteSpace(context.Item.InternetArchiveUrl))
{
@@ -65,7 +57,7 @@
</MudTooltip>
}
</CellTemplate>
</TemplateColumn>
</PropertyColumn>
<TemplateColumn Title="@L["Actions"]" Sortable="false" Filterable="false">
<CellTemplate>
<MudStack Row="true" Spacing="1">
@@ -77,5 +69,8 @@
</CellTemplate>
</TemplateColumn>
</Columns>
<PagerContent>
<MudDataGridPager T="BookDto" PageSizeOptions="new[] { 10, 25, 50, 100 }"/>
</PagerContent>
</MudDataGrid>
</MudPaper>

View File

@@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Marechai.ApiClient.Models;
using MudBlazor;
@@ -8,18 +11,82 @@ namespace Marechai.Pages.Admin;
public partial class Books
{
string _errorMessage;
bool _isLoading = true;
string _successMessage;
List<BookDto> _books;
string _errorMessage;
string _successMessage;
MudDataGrid<BookDto> _dataGrid;
protected override async Task OnInitializedAsync() => await LoadBooksAsync();
async Task LoadBooksAsync()
async Task<GridData<BookDto>> ServerReload(GridState<BookDto> state, CancellationToken cancellationToken)
{
_isLoading = true;
_books = await BooksService.GetBooksAsync();
_isLoading = false;
int skip = state.Page * state.PageSize;
int take = state.PageSize;
string sortBy = null;
bool sortDescending = false;
SortDefinition<BookDto> sort = state.SortDefinitions.FirstOrDefault();
if(sort is not null)
{
sortBy = sort.SortBy;
sortDescending = sort.Descending;
}
// Translate MudBlazor FilterDefinitions to the "{Column}||{Operator}||{Value}"
// wire format consumed by BooksController.ApplyFilters. Column comes from
// PropertyColumn binding via fd.Column?.PropertyName which matches the
// case labels in the controller switch (Title/Isbn/Edition/Pages/Published/
// Country/InternetArchiveUrl).
List<string> filters = null;
foreach(IFilterDefinition<BookDto> fd in state.FilterDefinitions)
{
string column = fd.Column?.PropertyName;
string op = fd.Operator;
if(string.IsNullOrEmpty(column) || string.IsNullOrEmpty(op)) continue;
bool isEmptyOp = op is "is empty" or "is not empty";
string value;
switch(fd.Value)
{
case null:
if(!isEmptyOp) continue;
value = string.Empty;
break;
case DateTime dt:
value = dt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
break;
case DateTimeOffset dto:
value = dto.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
break;
case IFormattable f:
value = f.ToString(null, CultureInfo.InvariantCulture);
break;
default:
value = fd.Value.ToString();
break;
}
// Skip non-empty-check operators with no meaningful value so a freshly
// opened (but unfilled) filter UI doesn't accidentally drop every row.
if(!isEmptyOp && string.IsNullOrEmpty(value)) continue;
filters ??= [];
filters.Add($"{column}||{op}||{value}");
}
Task<int> countTask = BooksService.GetBooksCountAsync(filters, cancellationToken);
Task<List<BookDto>> dataTask =
BooksService.GetPagedAsync(skip, take, sortBy, sortDescending, filters, cancellationToken);
await Task.WhenAll(countTask, dataTask);
return new GridData<BookDto>
{
Items = dataTask.Result,
TotalItems = countTask.Result
};
}
static string FormatDate(DateTimeOffset? date, int? precision = 0)
@@ -70,7 +137,7 @@ public partial class Books
if(id is not null)
{
_successMessage = L["Book created successfully."];
await LoadBooksAsync();
await _dataGrid.ReloadServerData();
}
else
{
@@ -138,7 +205,7 @@ public partial class Books
if(succeeded)
{
_successMessage = L["Book updated successfully."];
await LoadBooksAsync();
await _dataGrid.ReloadServerData();
}
else
{
@@ -160,7 +227,7 @@ public partial class Books
DialogResult result = await dialog.Result;
if(result is { Canceled: false })
await LoadBooksAsync();
await _dataGrid.ReloadServerData();
}
async Task ConfirmDeleteBook(BookDto book)
@@ -192,7 +259,7 @@ public partial class Books
if(succeeded)
{
_successMessage = L["Book deleted successfully."];
await LoadBooksAsync();
await _dataGrid.ReloadServerData();
}
else
{

View File

@@ -25,6 +25,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Marechai.ApiClient.Models;
@@ -34,11 +35,15 @@ namespace Marechai.Services;
public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache referenceData)
{
public async Task<int> GetBooksCountAsync()
public async Task<int> GetBooksCountAsync(IReadOnlyList<string> filters = null,
CancellationToken cancellationToken = default)
{
try
{
int? count = await client.Books.Count.GetAsync();
int? count = await client.Books.Count.GetAsync(config =>
{
if(filters is { Count: > 0 }) config.QueryParameters.Filters = filters.ToArray();
}, cancellationToken);
return count ?? 0;
}
@@ -189,6 +194,37 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r
}
}
/// <summary>
/// Server-side paged fetch used by the admin <c>MudDataGrid</c>. Pushes the
/// sort + filter spec all the way down to the SQL query so paging,
/// filtering and ordering happen in the database. Filters are
/// <c>"{Column}||{Operator}||{Value}"</c> triples mirroring MudBlazor's
/// <c>FilterDefinition</c> shape; <c>BooksController.ApplyFilters</c>
/// translates them into LINQ predicates.
/// </summary>
public async Task<List<BookDto>> GetPagedAsync(int skip, int take, string sortBy, bool sortDescending,
IReadOnlyList<string> filters,
CancellationToken cancellationToken = default)
{
try
{
List<BookDto> books = await client.Books.GetAsync(config =>
{
config.QueryParameters.Skip = skip;
config.QueryParameters.Take = take;
if(!string.IsNullOrWhiteSpace(sortBy)) config.QueryParameters.SortBy = sortBy;
if(sortDescending) config.QueryParameters.SortDescending = true;
if(filters is { Count: > 0 }) config.QueryParameters.Filters = filters.ToArray();
}, cancellationToken);
return books ?? [];
}
catch
{
return [];
}
}
public async Task<BookDto> GetBookAsync(long id)
{
try