From 7c19937bcebdf4ddd9b47ef8bad447453c3223ac Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 21 Jul 2023 23:10:50 +0200 Subject: [PATCH] Implemented pagination in profile list --- .../Controls/Pagination/Pagination.cs | 8 +- .../Pagination/Pagination.properties.cs | 3 +- .../Routing/Router/Navigation.cs | 6 +- .../Categories/CategoriesViewModel.cs | 64 +++++++------ .../Workshop/Entries/EntryListViewModel.cs | 4 +- .../Workshop/Profile/ProfileListView.axaml | 42 +++++--- .../Workshop/Profile/ProfileListViewModel.cs | 96 ++++++++++++------- .../Artemis.WebClient.Workshop.csproj | 2 + .../Extensions/ReactiveExtensions.cs | 30 ++++++ .../Queries/GetEntries.graphql | 7 +- src/Artemis.WebClient.Workshop/schema.graphql | 82 +++++++--------- 11 files changed, 210 insertions(+), 134 deletions(-) create mode 100644 src/Artemis.WebClient.Workshop/Extensions/ReactiveExtensions.cs diff --git a/src/Artemis.UI.Shared/Controls/Pagination/Pagination.cs b/src/Artemis.UI.Shared/Controls/Pagination/Pagination.cs index 26352ee17..35b4fa96e 100644 --- a/src/Artemis.UI.Shared/Controls/Pagination/Pagination.cs +++ b/src/Artemis.UI.Shared/Controls/Pagination/Pagination.cs @@ -51,18 +51,20 @@ public partial class Pagination : TemplatedControl private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { - if (e.Property == ValueProperty) + if (e.Property == ValueProperty || e.Property == MaximumProperty) Update(); } private void NextButtonOnClick(object? sender, RoutedEventArgs e) { - Value++; + if (Value < Maximum) + Value++; } private void PreviousButtonOnClick(object? sender, RoutedEventArgs e) { - Value--; + if (Value > 1) + Value--; } private void Update() diff --git a/src/Artemis.UI.Shared/Controls/Pagination/Pagination.properties.cs b/src/Artemis.UI.Shared/Controls/Pagination/Pagination.properties.cs index 989e13038..65ff27ed4 100644 --- a/src/Artemis.UI.Shared/Controls/Pagination/Pagination.properties.cs +++ b/src/Artemis.UI.Shared/Controls/Pagination/Pagination.properties.cs @@ -1,6 +1,7 @@ using System; using Avalonia; using Avalonia.Controls.Primitives; +using Avalonia.Data; namespace Artemis.UI.Shared.Pagination; @@ -10,7 +11,7 @@ public partial class Pagination : TemplatedControl /// Defines the property /// public static readonly StyledProperty ValueProperty = - AvaloniaProperty.Register(nameof(Value), 1, enableDataValidation: true, coerce: (p, v) => Math.Clamp(v, 1, ((Pagination) p).Maximum)); + AvaloniaProperty.Register(nameof(Value), 1, defaultBindingMode: BindingMode.TwoWay); /// /// Defines the property diff --git a/src/Artemis.UI.Shared/Routing/Router/Navigation.cs b/src/Artemis.UI.Shared/Routing/Router/Navigation.cs index ac171100f..567a7b719 100644 --- a/src/Artemis.UI.Shared/Routing/Router/Navigation.cs +++ b/src/Artemis.UI.Shared/Routing/Router/Navigation.cs @@ -78,7 +78,8 @@ internal class Navigation catch (Exception e) { Cancel(); - _logger.Error(e, "Failed to navigate to {Path}", resolution.Path); + if (e is not TaskCanceledException) + _logger.Error(e, "Failed to navigate to {Path}", resolution.Path); } } @@ -96,7 +97,8 @@ internal class Navigation catch (Exception e) { Cancel(); - _logger.Error(e, "Failed to navigate to {Path}", resolution.Path); + if (e is not TaskCanceledException) + _logger.Error(e, "Failed to navigate to {Path}", resolution.Path); } if (CancelIfRequested(args, "OnNavigating", screen)) diff --git a/src/Artemis.UI/Screens/Workshop/Categories/CategoriesViewModel.cs b/src/Artemis.UI/Screens/Workshop/Categories/CategoriesViewModel.cs index e3b1e0464..a72737141 100644 --- a/src/Artemis.UI/Screens/Workshop/Categories/CategoriesViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Categories/CategoriesViewModel.cs @@ -1,56 +1,64 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Disposables; -using System.Threading; -using System.Threading.Tasks; +using System.Reactive.Linq; using Artemis.UI.Shared; using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Extensions; using DynamicData; +using DynamicData.Binding; using ReactiveUI; -using Serilog; using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Categories; public class CategoriesViewModel : ActivatableViewModelBase { - private readonly IWorkshopClient _client; - private readonly ILogger _logger; - public readonly SourceList _categories; + private ObservableAsPropertyHelper?>? _categoryFilters; - public CategoriesViewModel(ILogger logger, IWorkshopClient client) + public CategoriesViewModel(IWorkshopClient client) { - _logger = logger; - _client = client; - _categories = new SourceList(); - _categories.Connect().Bind(out ReadOnlyObservableCollection categoryViewModels).Subscribe(); - + client.GetCategories + .Watch(ExecutionStrategy.CacheFirst) + .SelectOperationResult(c => c.Categories) + .ToObservableChangeSet(c => c.Id) + .Transform(c => new CategoryViewModel(c)) + .Bind(out ReadOnlyObservableCollection categoryViewModels) + .Subscribe(); + Categories = categoryViewModels; - this.WhenActivated(d => ReactiveCommand.CreateFromTask(GetCategories).Execute().Subscribe().DisposeWith(d)); + + this.WhenActivated(d => + { + _categoryFilters = Categories.ToObservableChangeSet() + .AutoRefresh(c => c.IsSelected) + .Filter(e => e.IsSelected) + .Select(_ => CreateFilter()) + .ToProperty(this, vm => vm.CategoryFilters) + .DisposeWith(d); + }); } public ReadOnlyObservableCollection Categories { get; } + public IReadOnlyList? CategoryFilters => _categoryFilters?.Value; - - private async Task GetCategories(CancellationToken cancellationToken) + private IReadOnlyList? CreateFilter() { - try - { - IOperationResult result = await _client.GetCategories.ExecuteAsync(cancellationToken); - if (result.IsErrorResult()) - _logger.Warning("Failed to retrieve categories {Error}", result.Errors); + List categories = Categories.Where(c => c.IsSelected).Select(c => (int?) c.Id).ToList(); + if (!categories.Any()) + return null; - _categories.Edit(l => + List categoryFilters = new(); + foreach (int? category in categories) + { + categoryFilters.Add(new EntryFilterInput { - l.Clear(); - if (result.Data?.Categories != null) - l.AddRange(result.Data.Categories.Select(c => new CategoryViewModel(c))); + Categories = new ListFilterInputTypeOfCategoryFilterInput {Some = new CategoryFilterInput {Id = new IntOperationFilterInput {Eq = category}}} }); } - catch (Exception e) - { - _logger.Warning(e, "Failed to retrieve categories"); - } + + return categoryFilters; } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs index 7a318fc27..dc393b80c 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs @@ -10,13 +10,13 @@ public class EntryListViewModel : ViewModelBase { private readonly IRouter _router; - public EntryListViewModel(IGetEntries_Entries_Nodes entry, IRouter router) + public EntryListViewModel(IGetEntries_Entries_Items entry, IRouter router) { _router = router; Entry = entry; } - public IGetEntries_Entries_Nodes Entry { get; } + public IGetEntries_Entries_Items Entry { get; } public async Task NavigateToEntry() { diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml index 38380c8e7..586bcdd75 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml @@ -3,28 +3,48 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:profile="clr-namespace:Artemis.UI.Screens.Workshop.Profile" + xmlns:pagination="clr-namespace:Artemis.UI.Shared.Pagination;assembly=Artemis.UI.Shared" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Workshop.Profile.ProfileListView" x:DataType="profile:ProfileListViewModel"> - - - - Categories - - - - + + + + Categories + + + + - - + + Filters + + + + + + + + + + + + - + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs index ecbdb28ff..9ff38f6f0 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Disposables; +using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; using Artemis.UI.Screens.Workshop.Categories; @@ -11,8 +11,6 @@ using Artemis.UI.Screens.Workshop.Parameters; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop; -using DynamicData; -using DynamicData.Alias; using ReactiveUI; using StrawberryShake; @@ -20,35 +18,44 @@ namespace Artemis.UI.Screens.Workshop.Profile; public class ProfileListViewModel : RoutableScreen, IWorkshopViewModel { - private readonly SourceList _entries; + private readonly IRouter _router; private readonly IWorkshopClient _workshopClient; + private readonly ObservableAsPropertyHelper _showPagination; + private List? _entries; private int _page; + private int _totalPages = 1; + private int _entriesPerPage = 5; public ProfileListViewModel(IWorkshopClient workshopClient, IRouter router, CategoriesViewModel categoriesViewModel) { _workshopClient = workshopClient; + _router = router; + _showPagination = this.WhenAnyValue(vm => vm.TotalPages).Select(t => t > 1).ToProperty(this, vm => vm.ShowPagination); + CategoriesViewModel = categoriesViewModel; - _entries = new SourceList(); - _entries.Connect() - .Transform(e => new EntryListViewModel(e, router)) - .Bind(out ReadOnlyObservableCollection observableEntries) - .Subscribe(); - - this.WhenActivated(d => + // Respond to page changes + this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => _router.Navigate($"workshop/profiles/{p}"))); + // Respond to filter changes + this.WhenActivated(d => CategoriesViewModel.WhenAnyValue(vm => vm.CategoryFilters).Skip(1).Subscribe(_ => { - CategoriesViewModel._categories.Connect() - .AutoRefresh(c => c.IsSelected) - .Filter(e => e.IsSelected) - .Select(e => e.Id) - .Subscribe(_ => ReactiveCommand.CreateFromTask(GetEntries).Execute().Subscribe()) - .DisposeWith(d); - }); - Entries = observableEntries; + // Reset to page one, will trigger a query + if (Page != 1) + Page = 1; + // If already at page one, force a query + else + Task.Run(() => Query(CancellationToken.None)); + }).DisposeWith(d)); } + public bool ShowPagination => _showPagination.Value; public CategoriesViewModel CategoriesViewModel { get; } - public ReadOnlyObservableCollection Entries { get; set; } + + public List? Entries + { + get => _entries; + set => RaiseAndSetIfChanged(ref _entries, value); + } public int Page { @@ -56,32 +63,49 @@ public class ProfileListViewModel : RoutableScreen RaiseAndSetIfChanged(ref _page, value); } + public int TotalPages + { + get => _totalPages; + set => RaiseAndSetIfChanged(ref _totalPages, value); + } + + public int EntriesPerPage + { + get => _entriesPerPage; + set => RaiseAndSetIfChanged(ref _entriesPerPage, value); + } + public override async Task OnNavigating(WorkshopListParameters parameters, NavigationArguments args, CancellationToken cancellationToken) { Page = Math.Max(1, parameters.Page); - await GetEntries(cancellationToken); + + // Throttle page changes + await Task.Delay(200, cancellationToken); + + if (!cancellationToken.IsCancellationRequested) + await Query(cancellationToken); } - private async Task GetEntries(CancellationToken cancellationToken) + private async Task Query(CancellationToken cancellationToken) { - IOperationResult result = await _workshopClient.GetEntries.ExecuteAsync(CreateFilter(), cancellationToken); - if (result.IsErrorResult() || result.Data?.Entries?.Nodes == null) - return; - - _entries.Edit(e => + EntryFilterInput filter = GetFilter(); + IOperationResult entries = await _workshopClient.GetEntries.ExecuteAsync(filter, EntriesPerPage * (Page - 1), EntriesPerPage, cancellationToken); + if (!entries.IsErrorResult() && entries.Data?.Entries?.Items != null) { - e.Clear(); - e.AddRange(result.Data.Entries.Nodes); - }); + Entries = entries.Data.Entries.Items.Select(n => new EntryListViewModel(n, _router)).ToList(); + TotalPages = (int) Math.Ceiling(entries.Data.Entries.TotalCount / (double) EntriesPerPage); + } + else + TotalPages = 1; } - private EntryFilterInput CreateFilter() + private EntryFilterInput GetFilter() { - EntryFilterInput filter = new() {EntryType = new EntryTypeOperationFilterInput {Eq = WebClient.Workshop.EntryType.Profile}}; - - List categories = CategoriesViewModel.Categories.Where(c => c.IsSelected).Select(c => (int?) c.Id).ToList(); - if (categories.Any()) - filter.Categories = new ListFilterInputTypeOfCategoryFilterInput {All = new CategoryFilterInput {Id = new IntOperationFilterInput {In = categories}}}; + EntryFilterInput filter = new() + { + EntryType = new EntryTypeOperationFilterInput {Eq = WebClient.Workshop.EntryType.Profile}, + And = CategoriesViewModel.CategoryFilters + }; return filter; } diff --git a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj index 1ba5f26ae..e09fefe42 100644 --- a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj +++ b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj @@ -13,8 +13,10 @@ + + diff --git a/src/Artemis.WebClient.Workshop/Extensions/ReactiveExtensions.cs b/src/Artemis.WebClient.Workshop/Extensions/ReactiveExtensions.cs new file mode 100644 index 000000000..266574c01 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Extensions/ReactiveExtensions.cs @@ -0,0 +1,30 @@ +using System.Reactive.Linq; +using ReactiveUI; +using StrawberryShake; + +namespace Artemis.WebClient.Workshop.Extensions; + +public static class ReactiveExtensions +{ + /// + /// Projects the data of the provided operation result into a new observable sequence if the result is successfull and + /// contains data. + /// + /// A sequence of operation results to invoke a transform function on. + /// A transform function to apply to the data of each source element. + /// The type of data contained in the operation result. + /// The type of data to project from the result. + /// + /// An observable sequence whose elements are the result of invoking the transform function on each element of + /// source. + /// + public static IObservable SelectOperationResult(this IObservable> source, Func selector) where TSource : class + { + return source + .Where(s => !s.Errors.Any()) + .Select(s => s.Data) + .WhereNotNull() + .Select(selector) + .WhereNotNull(); + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql b/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql index 3ae3fc34b..c7e639c31 100644 --- a/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql @@ -1,6 +1,7 @@ -query GetEntries($filter: EntryFilterInput) { - entries(where: $filter) { - nodes { +query GetEntries($filter: EntryFilterInput $skip: Int $take: Int) { + entries(where: $filter skip: $skip take: $take) { + totalCount + items { id author name diff --git a/src/Artemis.WebClient.Workshop/schema.graphql b/src/Artemis.WebClient.Workshop/schema.graphql index c41fd39ba..59efce290 100644 --- a/src/Artemis.WebClient.Workshop/schema.graphql +++ b/src/Artemis.WebClient.Workshop/schema.graphql @@ -11,24 +11,21 @@ type Category { name: String! } -"A connection to a list of items." -type EntriesConnection { - "A list of edges." - edges: [EntriesEdge!] - "A flattened list of the nodes." - nodes: [Entry!] - "Information to aid in pagination." - pageInfo: PageInfo! - "Identifies the total count of items in the connection." - totalCount: Int! +"Information about the offset pagination." +type CollectionSegmentInfo { + "Indicates whether more items exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more items exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! } -"An edge in a connection." -type EntriesEdge { - "A cursor for use in pagination." - cursor: String! - "The item at the end of the edge." - node: Entry! +"A segment of a collection." +type EntriesCollectionSegment { + "A flattened list of the items." + items: [Entry!] + "Information to aid in pagination." + pageInfo: CollectionSegmentInfo! + totalCount: Int! } type Entry { @@ -54,35 +51,13 @@ type Image { } type Mutation { - addEntry(input: EntryInput!): Entry -} - -"Information about pagination in a connection." -type PageInfo { - "When paginating forwards, the cursor to continue." - endCursor: String - "Indicates whether more edges exist following the set defined by the clients arguments." - hasNextPage: Boolean! - "Indicates whether more edges exist prior the set defined by the clients arguments." - hasPreviousPage: Boolean! - "When paginating backwards, the cursor to continue." - startCursor: String + addEntry(input: CreateEntryInput!): Entry + updateEntry(input: UpdateEntryInput!): Entry } type Query { categories(order: [CategorySortInput!], where: CategoryFilterInput): [Category!]! - entries( - "Returns the elements in the list that come after the specified cursor." - after: String, - "Returns the elements in the list that come before the specified cursor." - before: String, - "Returns the first _n_ elements from the list." - first: Int, - "Returns the last _n_ elements from the list." - last: Int, - order: [EntrySortInput!], - where: EntryFilterInput - ): EntriesConnection + entries(order: [EntrySortInput!], skip: Int, take: Int, where: EntryFilterInput): EntriesCollectionSegment entry(id: UUID!): Entry searchEntries(input: String!, order: [EntrySortInput!], type: EntryType, where: EntryFilterInput): [Entry!]! } @@ -142,6 +117,15 @@ input CategorySortInput { name: SortEnumType } +input CreateEntryInput { + categories: [Int!]! + description: String! + entryType: EntryType! + name: String! + summary: String! + tags: [String!]! +} + input DateTimeOperationFilterInput { eq: DateTime gt: DateTime @@ -176,13 +160,6 @@ input EntryFilterInput { tags: ListFilterInputTypeOfTagFilterInput } -input EntryInput { - description: String! - entryType: EntryType! - name: String! - tags: [String!]! -} - input EntrySortInput { author: SortEnumType authorId: SortEnumType @@ -308,6 +285,15 @@ input TagFilterInput { or: [TagFilterInput!] } +input UpdateEntryInput { + categories: [Int!]! + description: String! + id: UUID! + name: String! + summary: String! + tags: [String!]! +} + input UuidOperationFilterInput { eq: UUID gt: UUID