mirror of
https://github.com/claunia/marechai.git
synced 2026-07-08 17:57:08 +00:00
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:
@@ -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<global::Marechai.ApiClient.Models.BookDto></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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"descriptionHash": "39187644E5EA6F6455F9D2812652A572265D56A88F88621125F1ED8A8E6635971826C01E1AE8433383B27AB82E235F5741021EB6D25A2E44E3CAFE011B8DAA4D",
|
||||
"descriptionHash": "001FF5857D8AF75D32CA7A7B73390EED7EB388BD199A32E13EE496AF1FE7FD989B93DEBF12A7208B67DC427E3F9E52A029A8FFB60FC4E9563807A0D888DCD263",
|
||||
"descriptionLocation": "../../../../../tmp/openapi.json",
|
||||
"lockFileVersion": "1.0.0",
|
||||
"kiotaVersion": "1.31.1",
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user