diff --git a/.github/prompts/plan-prospectPerformance.prompt.md b/.github/prompts/plan-prospectPerformance.prompt.md new file mode 100644 index 0000000..1beec69 --- /dev/null +++ b/.github/prompts/plan-prospectPerformance.prompt.md @@ -0,0 +1,56 @@ +## Plan: Prospect list performance overhaul + +The current bottleneck is not just data volume; it is the combination of loading every prospect and every nested interaction at once, then rendering all cards in the browser. In the current flow, each page loads the full list from `ProspectService.GetProspectsAsync`, includes all `Images` and `Interactions` plus each `User`, and then `ProspectGrid` renders every card through `Repeater` without virtualization. With hundreds of records, the UI does repeated LINQ work per card and hits a lot of DOM work in one render. + +### Recommended approach +Use a two-part fix: reduce the server-side payload and virtualize the client-side rendering. The best gains will come from paginating or virtualizing the list, then trimming unnecessary nested load and repeated per-row calculations. + +### Steps +1. Profile the bottleneck and cap the visible list size + - Confirm the prospect pages are loading all records at once because `GetProspectsAsync` currently does `ToListAsync()` without paging or limit. + - Add a page-size/virtualized flow so only the visible slice is fetched and rendered. + - This change must happen in the page and service layer before any UI refactor. + +2. Reduce EF payload in `ProspectService.GetProspectsAsync` + - Update `FoodsharingSiegen.Server/Data/Service/ProspectService.cs` so it accepts pagination arguments (`skip`, `take`, optional filters) instead of returning the full unbounded list. + - Keep the query as `AsNoTracking()` but avoid eager-loading all `Images` and every `User` for every prospect when the page only needs a summary view. + - For the list pages, load only the subset needed for the grid and defer expensive image/user data until a detail action opens. + - Filter and sort should happen server-side when possible to avoid moving hundreds of full entities into memory and then filtering again in the browser. + +3. Replace the full list render with virtualization + - Convert the repeated list in `FoodsharingSiegen.Server/Controls/ProspectGrid.razor` to a virtualized list or paged container, rather than rendering a full `` for all rows. + - Keep the visible cards only; the hidden rows should not be in the DOM. + - This removes the biggest DOM cost on pages with hundreds of entries. + +4. Remove repeated per-row computation in the card components + - In `FoodsharingSiegen.Server/Controls/InteractionRow.razor.cs`, the properties `Interactions`, `Done`, `Alert`, `NotNeeded` recalculate on every render from a list of nested items. With hundreds of cards this becomes expensive. + - Replace repeated `Where()` chains with precomputed values from the loaded model or cached values at the prospect object level. + - In `FoodsharingSiegen.Contracts/Entity/Prospect.cs`, avoid deriving `Complete` from `Interactions.Any()` on every render when a summary value is already known in the data model. + - Keep the card logic free of repeated `ToList()` and `Any()` chains during render. + +5. Tighten data shaping for each list page + - The onboarding, verification, archive, and done pages all load the same full set of entities and then apply client-side filters with `ApplyFilter` and `ApplySort` in `FoodsharingSiegen.Shared/Helper/FilterHelper.cs`. + - Move the expensive filtering/sorting to SQL or at least to the server-side query layer; reduce the list to a smaller result set before it reaches the UI. + - Keep `ProspectList.ApplyFilter` only as a fallback for small local slices, not as the primary path for large lists. + +6. Verify the improvement with focused checks + - Measure page load time before and after with a realistic dataset of several hundred prospects. + - Validate that the list stays responsive during scrolling and interaction changes. + - Run the relevant .NET tests and render-check the prospect pages manually. + +### Relevant files +- `FoodsharingSiegen.Server/Data/Service/ProspectService.cs` — main query and database fetch point. +- `FoodsharingSiegen.Server/Controls/ProspectGrid.razor` — full-list rendering path. +- `FoodsharingSiegen.Server/Controls/ProspectContainer.razor` — per-card DOM and nested component rendering. +- `FoodsharingSiegen.Server/Controls/InteractionRow.razor.cs` — repeated LINQ work in each row. +- `FoodsharingSiegen.Contracts/Entity/Prospect.cs` — computed values that trigger repeated work. +- `FoodsharingSiegen.Shared/Helper/FilterHelper.cs` — client-side list filtering/sorting on large sets. +- `FoodsharingSiegen.Server/Pages/Prospects.razor`, `FoodsharingSiegen.Server/Pages/ProspectsAll.razor`, `FoodsharingSiegen.Server/Pages/ProspectsVerify.razor`, `FoodsharingSiegen.Server/Pages/ProspectsDone.razor` — list page orchestration and data flow. + +### Key decision +The highest-value fix is to stop returning and rendering all prospects at once. A virtualized/paged list with a trimmed query shape will provide the biggest speedup and should be the first implementation target. + +### Optional follow-up improvements +- Precompute a lightweight summary model for the grid instead of the full `Prospect` entity. +- Add a server-side count query to support proper paging without loading every record. +- Consider splitting the list into “summary” and “detail” data so the main dashboard never loads full nested relationship graphs by default. diff --git a/FoodsharingSiegen.Contracts/Model/Parameters.cs b/FoodsharingSiegen.Contracts/Model/Parameters.cs index 37535eb..ddd5222 100644 --- a/FoodsharingSiegen.Contracts/Model/Parameters.cs +++ b/FoodsharingSiegen.Contracts/Model/Parameters.cs @@ -6,5 +6,10 @@ namespace FoodsharingSiegen.Contracts.Model /// /// The get prospects parameter /// - public record GetProspectsParameter(List? MustHaveInteractions = null, List? CannotHaveInteractions = null, bool IncludeDeleted = false); + public record GetProspectsParameter( + List? MustHaveInteractions = null, + List? CannotHaveInteractions = null, + bool IncludeDeleted = false, + int Skip = 0, + int Take = 50); } \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Controls/InteractionRow.razor.cs b/FoodsharingSiegen.Server/Controls/InteractionRow.razor.cs index 5910a7c..157f94e 100644 --- a/FoodsharingSiegen.Server/Controls/InteractionRow.razor.cs +++ b/FoodsharingSiegen.Server/Controls/InteractionRow.razor.cs @@ -97,7 +97,9 @@ namespace FoodsharingSiegen.Server.Controls /// /// Gets the value of the interactions (ab) /// - private List Interactions => Prospect?.Interactions.Where(x => x.Type == Type).ToList() ?? []; + private List Interactions => Prospect?.Interactions is null + ? [] + : [.. Prospect.Interactions.Where(x => x.Type == Type)]; /// /// Gets the value of the not needed (ab) diff --git a/FoodsharingSiegen.Server/Controls/ProspectGrid.razor b/FoodsharingSiegen.Server/Controls/ProspectGrid.razor index 499253d..92f84da 100644 --- a/FoodsharingSiegen.Server/Controls/ProspectGrid.razor +++ b/FoodsharingSiegen.Server/Controls/ProspectGrid.razor @@ -1,4 +1,7 @@ @using FoodsharingSiegen.Contracts.Enums +@using Microsoft.JSInterop +@implements IAsyncDisposable +@inject IJSRuntime JSRuntime @code { [Parameter] public List? Prospects { get; set; } @@ -7,19 +10,95 @@ [Parameter] public Func? OnDataChanged { get; set; } + [Parameter] public EventCallback OnLoadMore { get; set; } + + [Parameter] public bool IsLoadingMore { get; set; } + + [Parameter] public bool HasMore { get; set; } + [Parameter] public string GridClass { get; set; } = string.Empty; + private IJSObjectReference? _module; + private DotNetObjectReference? _dotNetHelper; + private string _gridId = $"prospect-grid-{Guid.NewGuid():N}"; + private bool _isJsBound; + private bool _disposed; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_disposed || !firstRender || _isJsBound) return; + + try + { + _dotNetHelper ??= DotNetObjectReference.Create(this); + _module ??= await JSRuntime.InvokeAsync("import", "./js/prospect-grid.js"); + await _module.InvokeVoidAsync("attachScrollLoader", _gridId, _dotNetHelper); + _isJsBound = true; + } + catch (JSDisconnectedException) + { + // Ignore browser disconnect during teardown. + } + catch (ObjectDisposedException) + { + // Ignore late render after the component is being torn down. + } + } + + [JSInvokable] + public async Task OnScrollNearBottom() + { + if (OnLoadMore.HasDelegate && !IsLoadingMore && HasMore) + { + await OnLoadMore.InvokeAsync(); + } + } + + public async ValueTask DisposeAsync() + { + _disposed = true; + + if (_module is not null) + { + try + { + await _module.InvokeVoidAsync("disposeScrollLoader", _gridId); + } + catch (JSDisconnectedException) + { + // ignored; the circuit is already disconnected + } + catch (ObjectDisposedException) + { + // ignored; the JS runtime is already disposed + } + } + + _dotNetHelper?.Dispose(); + _dotNetHelper = null; + _isJsBound = false; + } + }
@(Prospects?.Count ?? 0) Ergebnisse
@if (Prospects?.Any() == true) { -
- - + @foreach (var prospect in Prospects) + { + - + } + + @if (IsLoadingMore) + { +
+ + Weitere laden... +
+ }
} \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Data/Service/ProspectService.cs b/FoodsharingSiegen.Server/Data/Service/ProspectService.cs index 117bbab..4179f77 100644 --- a/FoodsharingSiegen.Server/Data/Service/ProspectService.cs +++ b/FoodsharingSiegen.Server/Data/Service/ProspectService.cs @@ -115,18 +115,24 @@ namespace FoodsharingSiegen.Server.Data.Service .Include(x => x.Images) .Include(x => x.Interactions.OrderBy(i => i.Date)).ThenInclude(x => x.User) .OrderBy(x => x.Name).AsQueryable(); - - if(parameter.MustHaveInteractions != null && parameter.MustHaveInteractions.Any()) + + if (parameter.MustHaveInteractions != null && parameter.MustHaveInteractions.Any()) prospectsQuery = prospectsQuery.Where(x => x.Interactions.Any(i => parameter.MustHaveInteractions.Contains(i.Type))); - - if(parameter.CannotHaveInteractions != null && parameter.CannotHaveInteractions.Any()) + + if (parameter.CannotHaveInteractions != null && parameter.CannotHaveInteractions.Any()) prospectsQuery = prospectsQuery.Where(x => x.Interactions.All(i => !parameter.CannotHaveInteractions.Contains(i.Type))); - if (!parameter.IncludeDeleted) + if (!parameter.IncludeDeleted) prospectsQuery = prospectsQuery.Where(x => x.RecordState != RecordState.Archived); - + + if (parameter.Skip > 0) + prospectsQuery = prospectsQuery.Skip(parameter.Skip); + + if (parameter.Take > 0) + prospectsQuery = prospectsQuery.Take(parameter.Take); + var prospects = await prospectsQuery.ToListAsync(); - + return new(prospects); } catch (Exception e) diff --git a/FoodsharingSiegen.Server/Pages/Prospects.razor b/FoodsharingSiegen.Server/Pages/Prospects.razor index a243e5d..eebf3d3 100644 --- a/FoodsharingSiegen.Server/Pages/Prospects.razor +++ b/FoodsharingSiegen.Server/Pages/Prospects.razor @@ -29,13 +29,25 @@ @{ - var filterList = ProspectList.ApplyFilter(Filter); - var sortList = filterList.ApplySort(CurrentSort); + var filtered = ProspectList.ApplyFilter(Filter); + var sortList = filtered.ApplySort(CurrentSort); } - - - \ No newline at end of file +@if (IsLoadingProspects) +{ +
+ + Lade Einarbeitungen... +
+} +else +{ + + +} \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Pages/Prospects.razor.cs b/FoodsharingSiegen.Server/Pages/Prospects.razor.cs index 368add0..b77af6d 100644 --- a/FoodsharingSiegen.Server/Pages/Prospects.razor.cs +++ b/FoodsharingSiegen.Server/Pages/Prospects.razor.cs @@ -3,6 +3,7 @@ using FoodsharingSiegen.Contracts.Entity; using FoodsharingSiegen.Contracts.Enums; using FoodsharingSiegen.Contracts.Model; using FoodsharingSiegen.Server.Data.Service; +using FoodsharingSiegen.Shared.Helper; using FoodsharingSiegen.Server.Dialogs; using FoodsharingSiegen.Server.Service; using Microsoft.AspNetCore.Components; @@ -34,7 +35,17 @@ namespace FoodsharingSiegen.Server.Pages /// /// Gets or sets the value of the prospect list (ab) /// - private List? ProspectList { get; set; } + private List ProspectList { get; set; } = []; + + private bool IsLoadingProspects { get; set; } + + private bool IsLoadingMoreProspects { get; set; } + + private bool HasMoreProspects { get; set; } + + private int CurrentPageSize { get; set; } = 50; + + private int CurrentSkip { get; set; } private ProspectSortOption CurrentSort { get; set; } = ProspectSortOption.NameAscending; @@ -89,6 +100,9 @@ namespace FoodsharingSiegen.Server.Pages { Filter = arg; await LocalStorageService.SetItem(StorageKeys.ProspectFilter, Filter); + CurrentSkip = 0; + ProspectList = []; + await LoadProspects(); } #endregion @@ -98,17 +112,68 @@ namespace FoodsharingSiegen.Server.Pages /// /// Loads the prospects (a. beging, 11.04.2022) /// + private bool HasActiveFilter() + { + return !string.IsNullOrWhiteSpace(Filter.Text) + || Filter.WithoutStepInBriefing + || Filter.WithoutIdCheck + || Filter.DeletedOnly + || Filter.IdCheckPossible + || Filter.NoActivity + || Filter.RecentActivity; + } + private async Task LoadProspects() { + if (IsLoadingProspects || IsLoadingMoreProspects) return; + try { - var parameter = new GetProspectsParameter + if (CurrentSkip == 0) { - CannotHaveInteractions = [InteractionType.Complete, InteractionType.Verify, InteractionType.ReleasedForVerification] - }; + IsLoadingProspects = true; + } + else + { + IsLoadingMoreProspects = true; + } - var prospectsR = await ProspectService.GetProspectsAsync(parameter); - if (prospectsR.Success) ProspectList = prospectsR.Data; + await InvokeAsync(StateHasChanged); + + while (true) + { + var parameter = new GetProspectsParameter + { + Skip = CurrentSkip, + Take = CurrentPageSize, + CannotHaveInteractions = [InteractionType.Complete, InteractionType.Verify, InteractionType.ReleasedForVerification] + }; + + var prospectsR = await ProspectService.GetProspectsAsync(parameter); + if (!prospectsR.Success) + { + break; + } + + var loadedProspects = prospectsR.Data ?? []; + if (CurrentSkip == 0) + { + ProspectList = loadedProspects; + } + else + { + ProspectList = [.. ProspectList, .. loadedProspects]; + } + + CurrentSkip += loadedProspects.Count; + HasMoreProspects = loadedProspects.Count == CurrentPageSize; + + var filteredCount = ProspectList.ApplyFilter(Filter).Count; + if (!HasActiveFilter() || filteredCount > 0 || !HasMoreProspects || loadedProspects.Count == 0) + { + break; + } + } await InvokeAsync(StateHasChanged); } @@ -116,6 +181,18 @@ namespace FoodsharingSiegen.Server.Pages { await Notification.Error(ex.Message, "Fehler beim Laden"); } + finally + { + IsLoadingProspects = false; + IsLoadingMoreProspects = false; + await InvokeAsync(StateHasChanged); + } + } + + private async Task LoadMoreProspects() + { + if (IsLoadingProspects || IsLoadingMoreProspects || !HasMoreProspects) return; + await LoadProspects(); } #endregion diff --git a/FoodsharingSiegen.Server/Pages/ProspectsAll.razor b/FoodsharingSiegen.Server/Pages/ProspectsAll.razor index dc02cf8..fcca0ec 100644 --- a/FoodsharingSiegen.Server/Pages/ProspectsAll.razor +++ b/FoodsharingSiegen.Server/Pages/ProspectsAll.razor @@ -17,12 +17,25 @@ @{ - var filterList = ProspectList.ApplyFilter(Filter); - var sortList = filterList.ApplySort(CurrentSort); + var filtered = ProspectList.ApplyFilter(Filter); + var sortList = filtered.ApplySort(CurrentSort); } - - \ No newline at end of file +@if (IsLoadingProspects) +{ +
+ + Lade Einarbeitungen... +
+} +else +{ + + +} \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Pages/ProspectsAll.razor.cs b/FoodsharingSiegen.Server/Pages/ProspectsAll.razor.cs index e23cfcc..44d1708 100644 --- a/FoodsharingSiegen.Server/Pages/ProspectsAll.razor.cs +++ b/FoodsharingSiegen.Server/Pages/ProspectsAll.razor.cs @@ -5,6 +5,7 @@ using FoodsharingSiegen.Contracts.Helper; using FoodsharingSiegen.Contracts.Model; using FoodsharingSiegen.Server.Data.Service; using FoodsharingSiegen.Server.Dialogs; +using FoodsharingSiegen.Shared.Helper; using Microsoft.AspNetCore.Components; namespace FoodsharingSiegen.Server.Pages @@ -37,7 +38,17 @@ namespace FoodsharingSiegen.Server.Pages /// /// Gets or sets the value of the prospect list (ab) /// - private List? ProspectList { get; set; } + private List ProspectList { get; set; } = []; + + private bool IsLoadingProspects { get; set; } + + private bool IsLoadingMoreProspects { get; set; } + + private bool HasMoreProspects { get; set; } + + private int CurrentPageSize { get; set; } = 50; + + private int CurrentSkip { get; set; } private ProspectSortOption CurrentSort { get; set; } = ProspectSortOption.NameAscending; @@ -71,6 +82,9 @@ namespace FoodsharingSiegen.Server.Pages { Filter = arg; await LocalStorageService.SetItem(StorageKeys.ProspectFilter, Filter); + CurrentSkip = 0; + ProspectList = []; + await LoadProspects(); } #endregion @@ -80,17 +94,83 @@ namespace FoodsharingSiegen.Server.Pages /// /// Loads the prospects (a. beging, 11.04.2022) /// + private bool HasActiveFilter() + { + return !string.IsNullOrWhiteSpace(Filter.Text) + || Filter.WithoutStepInBriefing + || Filter.WithoutIdCheck + || Filter.DeletedOnly + || Filter.IdCheckPossible + || Filter.NoActivity + || Filter.RecentActivity; + } + private async Task LoadProspects() { - var parameter = new GetProspectsParameter - { - IncludeDeleted = true - }; - - var prospectsR = await ProspectService.GetProspectsAsync(parameter); - if (prospectsR.Success) ProspectList = prospectsR.Data; + if (IsLoadingProspects || IsLoadingMoreProspects) return; - await InvokeAsync(StateHasChanged); + try + { + if (CurrentSkip == 0) + { + IsLoadingProspects = true; + } + else + { + IsLoadingMoreProspects = true; + } + + await InvokeAsync(StateHasChanged); + + while (true) + { + var parameter = new GetProspectsParameter + { + Skip = CurrentSkip, + Take = CurrentPageSize, + IncludeDeleted = true + }; + + var prospectsR = await ProspectService.GetProspectsAsync(parameter); + if (!prospectsR.Success) + { + break; + } + + var loadedProspects = prospectsR.Data ?? []; + if (CurrentSkip == 0) + { + ProspectList = loadedProspects; + } + else + { + ProspectList = [.. ProspectList, .. loadedProspects]; + } + + CurrentSkip += loadedProspects.Count; + HasMoreProspects = loadedProspects.Count == CurrentPageSize; + + var filteredCount = ProspectList.ApplyFilter(Filter).Count; + if (!HasActiveFilter() || filteredCount > 0 || !HasMoreProspects || loadedProspects.Count == 0) + { + break; + } + } + + await InvokeAsync(StateHasChanged); + } + finally + { + IsLoadingProspects = false; + IsLoadingMoreProspects = false; + await InvokeAsync(StateHasChanged); + } + } + + private async Task LoadMoreProspects() + { + if (IsLoadingProspects || IsLoadingMoreProspects || !HasMoreProspects) return; + await LoadProspects(); } #endregion diff --git a/FoodsharingSiegen.Server/Pages/ProspectsDone.razor b/FoodsharingSiegen.Server/Pages/ProspectsDone.razor index 45bddb5..e3f3a2c 100644 --- a/FoodsharingSiegen.Server/Pages/ProspectsDone.razor +++ b/FoodsharingSiegen.Server/Pages/ProspectsDone.razor @@ -16,12 +16,25 @@ @{ - var filterList = ProspectList.ApplyFilter(Filter); - var sortList = filterList.ApplySort(CurrentSort); + var filtered = ProspectList.ApplyFilter(Filter); + var sortList = filtered.ApplySort(CurrentSort); } - - \ No newline at end of file +@if (IsLoadingProspects) +{ +
+ + Lade Einarbeitungen... +
+} +else +{ + + +} \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Pages/ProspectsDone.razor.cs b/FoodsharingSiegen.Server/Pages/ProspectsDone.razor.cs index 18677d3..3b803be 100644 --- a/FoodsharingSiegen.Server/Pages/ProspectsDone.razor.cs +++ b/FoodsharingSiegen.Server/Pages/ProspectsDone.razor.cs @@ -3,6 +3,7 @@ using FoodsharingSiegen.Contracts.Entity; using FoodsharingSiegen.Contracts.Enums; using FoodsharingSiegen.Contracts.Model; using FoodsharingSiegen.Server.Data.Service; +using FoodsharingSiegen.Shared.Helper; using Microsoft.AspNetCore.Components; namespace FoodsharingSiegen.Server.Pages @@ -29,7 +30,17 @@ namespace FoodsharingSiegen.Server.Pages /// /// Gets or sets the value of the prospect list (ab) /// - private List? ProspectList { get; set; } + private List ProspectList { get; set; } = []; + + private bool IsLoadingProspects { get; set; } + + private bool IsLoadingMoreProspects { get; set; } + + private bool HasMoreProspects { get; set; } + + private int CurrentPageSize { get; set; } = 50; + + private int CurrentSkip { get; set; } private ProspectSortOption CurrentSort { get; set; } = ProspectSortOption.NameAscending; @@ -60,6 +71,9 @@ namespace FoodsharingSiegen.Server.Pages { Filter = arg; await LocalStorageService.SetItem(StorageKeys.ProspectFilter, Filter); + CurrentSkip = 0; + ProspectList = []; + await LoadProspects(); } #endregion @@ -69,13 +83,82 @@ namespace FoodsharingSiegen.Server.Pages /// /// Loads the prospects (a. beging, 11.04.2022) /// + private bool HasActiveFilter() + { + return !string.IsNullOrWhiteSpace(Filter.Text) + || Filter.WithoutStepInBriefing + || Filter.WithoutIdCheck + || Filter.DeletedOnly + || Filter.IdCheckPossible + || Filter.NoActivity + || Filter.RecentActivity; + } + private async Task LoadProspects() { - var parameter = new GetProspectsParameter { MustHaveInteractions = [InteractionType.Complete] }; - var prospectsR = await ProspectService.GetProspectsAsync(parameter); - if (prospectsR.Success) ProspectList = prospectsR.Data; + if (IsLoadingProspects || IsLoadingMoreProspects) return; - await InvokeAsync(StateHasChanged); + try + { + if (CurrentSkip == 0) + { + IsLoadingProspects = true; + } + else + { + IsLoadingMoreProspects = true; + } + + await InvokeAsync(StateHasChanged); + + while (true) + { + var parameter = new GetProspectsParameter + { + Skip = CurrentSkip, + Take = CurrentPageSize, + MustHaveInteractions = [InteractionType.Complete] + }; + var prospectsR = await ProspectService.GetProspectsAsync(parameter); + if (!prospectsR.Success) + { + break; + } + + var loadedProspects = prospectsR.Data ?? []; + if (CurrentSkip == 0) + { + ProspectList = loadedProspects; + } + else + { + ProspectList = [.. ProspectList, .. loadedProspects]; + } + + CurrentSkip += loadedProspects.Count; + HasMoreProspects = loadedProspects.Count == CurrentPageSize; + + var filteredCount = ProspectList.ApplyFilter(Filter).Count; + if (!HasActiveFilter() || filteredCount > 0 || !HasMoreProspects || loadedProspects.Count == 0) + { + break; + } + } + + await InvokeAsync(StateHasChanged); + } + finally + { + IsLoadingProspects = false; + IsLoadingMoreProspects = false; + await InvokeAsync(StateHasChanged); + } + } + + private async Task LoadMoreProspects() + { + if (IsLoadingProspects || IsLoadingMoreProspects || !HasMoreProspects) return; + await LoadProspects(); } #endregion diff --git a/FoodsharingSiegen.Server/Pages/ProspectsVerify.razor b/FoodsharingSiegen.Server/Pages/ProspectsVerify.razor index afae1f6..e720a38 100644 --- a/FoodsharingSiegen.Server/Pages/ProspectsVerify.razor +++ b/FoodsharingSiegen.Server/Pages/ProspectsVerify.razor @@ -16,13 +16,25 @@ @{ - var filterList = ProspectList.ApplyFilter(Filter); - var sortList = filterList.ApplySort(CurrentSort); + var filtered = ProspectList.ApplyFilter(Filter); + var sortList = filtered.ApplySort(CurrentSort); } - - - \ No newline at end of file +@if (IsLoadingProspects) +{ +
+ + Lade Einarbeitungen... +
+} +else +{ + + +} \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Pages/ProspectsVerify.razor.cs b/FoodsharingSiegen.Server/Pages/ProspectsVerify.razor.cs index 502bdf4..a4c9a9d 100644 --- a/FoodsharingSiegen.Server/Pages/ProspectsVerify.razor.cs +++ b/FoodsharingSiegen.Server/Pages/ProspectsVerify.razor.cs @@ -3,6 +3,7 @@ using FoodsharingSiegen.Contracts.Entity; using FoodsharingSiegen.Contracts.Enums; using FoodsharingSiegen.Contracts.Model; using FoodsharingSiegen.Server.Data.Service; +using FoodsharingSiegen.Shared.Helper; using Microsoft.AspNetCore.Components; namespace FoodsharingSiegen.Server.Pages @@ -35,7 +36,17 @@ namespace FoodsharingSiegen.Server.Pages /// /// Gets or sets the value of the prospect list (ab) /// - private List? ProspectList { get; set; } + private List ProspectList { get; set; } = []; + + private bool IsLoadingProspects { get; set; } + + private bool IsLoadingMoreProspects { get; set; } + + private bool HasMoreProspects { get; set; } + + private int CurrentPageSize { get; set; } = 50; + + private int CurrentSkip { get; set; } private ProspectSortOption CurrentSort { get; set; } = ProspectSortOption.NameAscending; @@ -67,6 +78,9 @@ namespace FoodsharingSiegen.Server.Pages { Filter = arg; await LocalStorageService.SetItem(StorageKeys.ProspectFilter, Filter); + CurrentSkip = 0; + ProspectList = []; + await LoadProspects(); } #endregion @@ -76,17 +90,83 @@ namespace FoodsharingSiegen.Server.Pages /// /// Loads the prospects (a. beging, 11.04.2022) /// + private bool HasActiveFilter() + { + return !string.IsNullOrWhiteSpace(Filter.Text) + || Filter.WithoutStepInBriefing + || Filter.WithoutIdCheck + || Filter.DeletedOnly + || Filter.IdCheckPossible + || Filter.NoActivity + || Filter.RecentActivity; + } + private async Task LoadProspects() { - var parameter = new GetProspectsParameter - { - CannotHaveInteractions = [InteractionType.Complete], - MustHaveInteractions = [InteractionType.ReleasedForVerification] - }; - var prospectsR = await ProspectService.GetProspectsAsync(parameter); - if (prospectsR.Success) ProspectList = prospectsR.Data; + if (IsLoadingProspects || IsLoadingMoreProspects) return; - await InvokeAsync(StateHasChanged); + try + { + if (CurrentSkip == 0) + { + IsLoadingProspects = true; + } + else + { + IsLoadingMoreProspects = true; + } + + await InvokeAsync(StateHasChanged); + + while (true) + { + var parameter = new GetProspectsParameter + { + Skip = CurrentSkip, + Take = CurrentPageSize, + CannotHaveInteractions = [InteractionType.Complete], + MustHaveInteractions = [InteractionType.ReleasedForVerification] + }; + var prospectsR = await ProspectService.GetProspectsAsync(parameter); + if (!prospectsR.Success) + { + break; + } + + var loadedProspects = prospectsR.Data ?? []; + if (CurrentSkip == 0) + { + ProspectList = loadedProspects; + } + else + { + ProspectList = [.. ProspectList, .. loadedProspects]; + } + + CurrentSkip += loadedProspects.Count; + HasMoreProspects = loadedProspects.Count == CurrentPageSize; + + var filteredCount = ProspectList.ApplyFilter(Filter).Count; + if (!HasActiveFilter() || filteredCount > 0 || !HasMoreProspects || loadedProspects.Count == 0) + { + break; + } + } + + await InvokeAsync(StateHasChanged); + } + finally + { + IsLoadingProspects = false; + IsLoadingMoreProspects = false; + await InvokeAsync(StateHasChanged); + } + } + + private async Task LoadMoreProspects() + { + if (IsLoadingProspects || IsLoadingMoreProspects || !HasMoreProspects) return; + await LoadProspects(); } #endregion diff --git a/FoodsharingSiegen.Server/wwwroot/js/prospect-grid.js b/FoodsharingSiegen.Server/wwwroot/js/prospect-grid.js new file mode 100644 index 0000000..5481562 --- /dev/null +++ b/FoodsharingSiegen.Server/wwwroot/js/prospect-grid.js @@ -0,0 +1,23 @@ +export function attachScrollLoader(gridId, component) { + const grid = document.getElementById(gridId); + if (!grid) return; + + const checkScroll = () => { + const distanceFromBottom = grid.scrollHeight - (grid.scrollTop + grid.clientHeight); + if (distanceFromBottom < 250) { + component.invokeMethodAsync('OnScrollNearBottom'); + } + }; + + grid.addEventListener('scroll', checkScroll, { passive: true }); + grid.__prospectScrollCheck = checkScroll; + checkScroll(); +} + +export function disposeScrollLoader(gridId) { + const grid = document.getElementById(gridId); + if (!grid || !grid.__prospectScrollCheck) return; + + grid.removeEventListener('scroll', grid.__prospectScrollCheck); + delete grid.__prospectScrollCheck; +} diff --git a/FoodsharingSiegen.Tests/ProspectServiceTests.cs b/FoodsharingSiegen.Tests/ProspectServiceTests.cs new file mode 100644 index 0000000..7b07ac5 --- /dev/null +++ b/FoodsharingSiegen.Tests/ProspectServiceTests.cs @@ -0,0 +1,74 @@ +using FoodsharingSiegen.Contracts.Entity; +using FoodsharingSiegen.Contracts.Enums; +using FoodsharingSiegen.Contracts.Model; +using FoodsharingSiegen.Server.Auth; +using FoodsharingSiegen.Server.Data; +using FoodsharingSiegen.Server.Data.Service; +using FoodsharingSiegen.Server.Service; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.JSInterop; +using Moq; +using Xunit; + +namespace FoodsharingSiegen.Tests; + +public class ProspectServiceTests +{ + private static FsContext CreateInMemoryContext(string dbName) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: dbName) + .Options; + + return new FsContext(options); + } + + private static AuthService CreateAuthService(FsContext context) + { + var mockJsRuntime = new Mock(); + var localStorageService = new LocalStorageService(mockJsRuntime.Object); + var authStateProvider = new Mock(); + var mailService = new Mock(); + var appSettings = new Mock>(); + appSettings.Setup(x => x.Value).Returns(new AppSettings()); + + return new AuthService(context, localStorageService, authStateProvider.Object, mailService.Object, appSettings.Object); + } + + [Fact] + public async Task GetProspectsAsync_RespectsSkipAndTake() + { + var dbName = Guid.NewGuid().ToString(); + using var context = CreateInMemoryContext(dbName); + var authService = CreateAuthService(context); + var auditService = new AuditService(context, authService); + var prospectService = new ProspectService(context, authService, auditService); + + for (var i = 0; i < 5; i++) + { + context.Prospects!.Add(new Prospect + { + Id = Guid.NewGuid(), + Name = $"Prospect {i}", + Modified = DateTime.UtcNow.AddDays(-i), + Interactions = new List() + }); + } + + await context.SaveChangesAsync(); + + var result = await prospectService.GetProspectsAsync(new GetProspectsParameter + { + Skip = 1, + Take = 2, + IncludeDeleted = true + }); + + Assert.True(result.Success); + Assert.Equal(2, result.Data.Count); + Assert.Equal("Prospect 1", result.Data[0].Name); + Assert.Equal("Prospect 2", result.Data[1].Name); + } +}