From 95278492d2b8d09e69336a5704d8fa731e2abeab Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 3 Mar 2026 15:32:11 -0800 Subject: [PATCH 01/10] Update to .NET 10 - Following guide at https://learn.microsoft.com/en-us/aspnet/core/ migration/70-to-80?view=aspnetcore-10.0&tabs=visual-studio#convert-a-blazor-server-app-into-a-blazor-web -app --- src/apireview.net/App.razor | 86 +++++++++++++++---- src/apireview.net/Pages/_Host.cshtml | 67 --------------- src/apireview.net/Program.cs | 11 ++- .../Properties/launchSettings.json | 1 - src/apireview.net/Routes.razor | 17 ++++ src/apireview.net/_Imports.razor | 1 + src/apireview.net/apireview.net.csproj | 5 +- 7 files changed, 92 insertions(+), 96 deletions(-) delete mode 100644 src/apireview.net/Pages/_Host.cshtml create mode 100644 src/apireview.net/Routes.razor diff --git a/src/apireview.net/App.razor b/src/apireview.net/App.razor index 9ecbfbd..5460b55 100644 --- a/src/apireview.net/App.razor +++ b/src/apireview.net/App.razor @@ -1,19 +1,67 @@ - - - - - - - - - - - - -

Sorry, there's nothing at this address.

-
-
-
-
\ No newline at end of file +@inject IHostEnvironment Env + + + + + + + .NET API Review + + + + + + + + + + + +
+ @if (Env.IsDevelopment()) + { + + An unhandled exception has occurred. See browser dev tools for details. + + } + else + { + + An error has occurred. This app may no longer respond until reloaded. + + } + Reload + 🗙 +
+ + + + + + + + \ No newline at end of file diff --git a/src/apireview.net/Pages/_Host.cshtml b/src/apireview.net/Pages/_Host.cshtml deleted file mode 100644 index 2d000f9..0000000 --- a/src/apireview.net/Pages/_Host.cshtml +++ /dev/null @@ -1,67 +0,0 @@ -@page "/" -@namespace ApiReviewDotNet.Pages -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers -@{ - Layout = null; -} - - - - - - - .NET API Review - - - - - - - - - - - - -
- - An error has occurred. This application may no longer respond until reloaded. - - - An unhandled exception has occurred. See browser dev tools for details. - - Reload - 🗙 -
- - - - - - - - diff --git a/src/apireview.net/Program.cs b/src/apireview.net/Program.cs index a677818..fef57a4 100644 --- a/src/apireview.net/Program.cs +++ b/src/apireview.net/Program.cs @@ -20,7 +20,8 @@ { o.JsonSerializerOptions.Converters.Add(new TimeSpanJsonConverter()); }); -builder.Services.AddServerSideBlazor(); +builder.Services.AddRazorComponents(); +builder.Services.AddCascadingAuthenticationState(); builder.Services.AddControllers(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -64,7 +65,7 @@ RepositoryGroupService groupService = context.HttpContext.RequestServices.GetRequiredService(); GitHubMembershipService membershipService = context.HttpContext.RequestServices.GetRequiredService(); - string accessToken = context.AccessToken; + string? accessToken = context.AccessToken; string orgName = ApiReviewConstants.ApiApproverOrgName; IReadOnlyList teamSlugs = groupService.ApproverTeamSlugs; if (accessToken is not null && context.Identity?.Name is not null) @@ -101,14 +102,12 @@ app.UseHttpsRedirection(); app.UseStaticFiles(); -app.UseRouting(); - app.UseAuthentication(); app.UseAuthorization(); +app.UseAntiforgery(); -app.MapBlazorHub(); app.MapDefaultControllerRoute(); app.MapGitHubWebhooks(); -app.MapFallbackToPage("/_Host"); +app.MapRazorComponents(); app.Run(); diff --git a/src/apireview.net/Properties/launchSettings.json b/src/apireview.net/Properties/launchSettings.json index 2fb969f..169e8ff 100644 --- a/src/apireview.net/Properties/launchSettings.json +++ b/src/apireview.net/Properties/launchSettings.json @@ -10,7 +10,6 @@ "profiles": { "ApiReviewDotNet": { "commandName": "Project", - "dotnetRunMessages": "true", "launchBrowser": true, "hotReloadProfile": "aspnetcore", "applicationUrl": "https://localhost:5001;http://localhost:5000", diff --git a/src/apireview.net/Routes.razor b/src/apireview.net/Routes.razor new file mode 100644 index 0000000..58c7ee6 --- /dev/null +++ b/src/apireview.net/Routes.razor @@ -0,0 +1,17 @@ + + + + + + + + + + + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/src/apireview.net/_Imports.razor b/src/apireview.net/_Imports.razor index 2eae7db..c3aa33c 100644 --- a/src/apireview.net/_Imports.razor +++ b/src/apireview.net/_Imports.razor @@ -4,6 +4,7 @@ @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.JSInterop @using ApiReviewDotNet @using ApiReviewDotNet.Services diff --git a/src/apireview.net/apireview.net.csproj b/src/apireview.net/apireview.net.csproj index a4a3ed5..26b4eb3 100644 --- a/src/apireview.net/apireview.net.csproj +++ b/src/apireview.net/apireview.net.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable ApiReviewDotNet @@ -9,7 +9,7 @@ - + @@ -19,7 +19,6 @@ - From 95f8921dd6b113763aef08a2d395dc08183d29bd Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 3 Mar 2026 16:16:03 -0800 Subject: [PATCH 02/10] Fix non-interactivity --- .../Controllers/AdminController.cs | 18 ++ src/apireview.net/Data/TimeZoneService.cs | 26 --- src/apireview.net/Pages/Backlog.razor | 174 +++++++++++----- src/apireview.net/Pages/Backlog.razor.cs | 185 ++++-------------- src/apireview.net/Pages/Publish.razor | 1 + src/apireview.net/Pages/Schedule.razor | 37 +++- src/apireview.net/Pages/Schedule.razor.cs | 58 ++---- src/apireview.net/Program.cs | 7 +- src/apireview.net/Shared/MainLayout.razor | 13 +- 9 files changed, 233 insertions(+), 286 deletions(-) create mode 100644 src/apireview.net/Controllers/AdminController.cs delete mode 100644 src/apireview.net/Data/TimeZoneService.cs diff --git a/src/apireview.net/Controllers/AdminController.cs b/src/apireview.net/Controllers/AdminController.cs new file mode 100644 index 0000000..96a2a17 --- /dev/null +++ b/src/apireview.net/Controllers/AdminController.cs @@ -0,0 +1,18 @@ +using ApiReviewDotNet.Data; +using ApiReviewDotNet.Services; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ApiReviewDotNet.Controllers; + +public class AdminController : Controller +{ + [HttpPost("/admin/force-refresh")] + [Authorize(Roles = ApiReviewConstants.ApiApproverRole)] + public async Task ForceRefresh([FromServices] IssueService issueService) + { + await issueService.ReloadAsync(); + return Redirect("/"); + } +} diff --git a/src/apireview.net/Data/TimeZoneService.cs b/src/apireview.net/Data/TimeZoneService.cs deleted file mode 100644 index 0a34e0e..0000000 --- a/src/apireview.net/Data/TimeZoneService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.JSInterop; - -namespace ApiReviewDotNet.Data; - -public sealed class TimeZoneService -{ - private readonly IJSRuntime _jsRuntime; - - private TimeSpan? _userOffset; - - public TimeZoneService(IJSRuntime jsRuntime) - { - _jsRuntime = jsRuntime; - } - - public async ValueTask ToLocalAsync(DateTimeOffset dateTime) - { - if (_userOffset is null) - { - int offsetInMinutes = await _jsRuntime.InvokeAsync("getTimezoneOffset"); - _userOffset = TimeSpan.FromMinutes(-offsetInMinutes); - } - - return dateTime.ToOffset(_userOffset.Value); - } -} diff --git a/src/apireview.net/Pages/Backlog.razor b/src/apireview.net/Pages/Backlog.razor index d1a5228..e0af3fe 100644 --- a/src/apireview.net/Pages/Backlog.razor +++ b/src/apireview.net/Pages/Backlog.razor @@ -7,58 +7,60 @@ - +
+ @if (SelectedGroup != RepositoryGroupService.Default) + { + + } + + @foreach ((string k, bool v) in _milestones.Where(m => m.Value)) + { + + } + +
- @{ - bool isChecked = _checkedIssues.Count == Issues.Count; - } - +
- @if (_checkedIssues.Count == 0) - { -

- @{ - int numberOfOrgs = Issues.Select(i => i.Owner).Distinct().Count(); - int numberOfRepos = Issues.Select(i => i.Repo).Distinct().Count(); - int visibleIssues = VisibleIssues.Count(IsVisible); - } +

+ @{ + int numberOfOrgs = Issues.Select(i => i.Owner).Distinct().Count(); + int numberOfRepos = Issues.Select(i => i.Repo).Distinct().Count(); + int visibleIssues = VisibleIssues.Count(IsVisible); + } - @if (visibleIssues == Issues.Count) - { - - @Issues.Count issues across @numberOfOrgs orgs and @numberOfRepos repos - - } - else - { - - @visibleIssues of @Issues.Count issues across @numberOfOrgs orgs and @numberOfRepos repos - - } -

- } - else - { -
@if (_milestones is not null) @@ -68,19 +70,28 @@ - +
} @@ -96,10 +107,13 @@ string background = issue.IsBlocking ? "gh-issue-blocking" : ""; -
+
- +
@issue.Title.HighlightCode() @@ -135,7 +149,75 @@
- +
+ + +
+ + diff --git a/src/apireview.net/Pages/Backlog.razor.cs b/src/apireview.net/Pages/Backlog.razor.cs index b25d217..a867aa3 100644 --- a/src/apireview.net/Pages/Backlog.razor.cs +++ b/src/apireview.net/Pages/Backlog.razor.cs @@ -1,20 +1,14 @@ -using System.Text; - -using ApiReviewDotNet.Data; +using ApiReviewDotNet.Data; using ApiReviewDotNet.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Primitives; -using Microsoft.JSInterop; namespace ApiReviewDotNet.Pages; public sealed partial class Backlog : IDisposable { - [Inject] - private IJSRuntime JSRuntime { get; set; } = null!; - [Inject] public NavigationManager NavigationManager { get; set; } = null!; @@ -27,38 +21,15 @@ public sealed partial class Backlog : IDisposable private RepositoryGroup _selectedGroup = null!; private string _filter = null!; private SortedDictionary _milestones = null!; - private readonly HashSet _checkedIssues = new(); - private Task? _refresh; - private RepositoryGroup SelectedGroup - { - get => _selectedGroup; - set - { - if (_selectedGroup != value) - { - _selectedGroup = value; - ChangeUrl(); - } - } - } - - public string Filter - { - get => _filter; - set - { - if (_filter != value) - { - _filter = value; - ChangeUrl(); - } - } - } + public RepositoryGroup SelectedGroup => _selectedGroup; + public string Filter => _filter; public IReadOnlyList Issues => IssueService.Issues; public IEnumerable VisibleIssues => Issues.Where(IsVisible); - public IEnumerable SelectedIssues => VisibleIssues.Where(_checkedIssues.Contains); + + public string SelectAllMilestonesUrl => BuildMilestoneUrl(); + public string SelectNoneMilestonesUrl => BuildMilestoneUrl("m=__none__"); public int GetRank(ApiReviewIssue issue) { @@ -70,7 +41,7 @@ public int GetRank(ApiReviewIssue issue) return -1; } - + protected override void OnInitialized() { _selectedGroup = RepositoryGroupService.Default; @@ -91,11 +62,28 @@ protected override void OnInitialized() if (queryParameters.TryGetValue("q", out StringValues q)) _filter = q!; - if (queryParameters.TryGetValue("m", out StringValues selectedMilestones)) + // m_submitted sentinel distinguishes form POST (where unchecked = absent) from direct URL nav. + // Without the sentinel, no m params = all milestones selected (default). + if (queryParameters.ContainsKey("m_submitted")) { foreach (string m in _milestones.Keys.ToArray()) _milestones[m] = false; + if (queryParameters.TryGetValue("m", out StringValues submittedMilestones)) + { + foreach (string? m in submittedMilestones) + { + if (_milestones.ContainsKey(m!)) + _milestones[m!] = true; + } + } + } + else if (queryParameters.TryGetValue("m", out StringValues selectedMilestones)) + { + // Direct URL navigation (Select All/None links or manual URL) + foreach (string m in _milestones.Keys.ToArray()) + _milestones[m] = false; + foreach (string? m in selectedMilestones) { if (_milestones.ContainsKey(m!)) @@ -111,38 +99,22 @@ public void Dispose() IssueService.Changed -= IssuesChanged; } - private async void ChangeUrl() + private string BuildMilestoneUrl(string? extraParam = null) { - string query = ""; - - if (SelectedGroup != RepositoryGroupService.Default) - query += $"?g={Uri.EscapeDataString(SelectedGroup.Name)}"; - - if (!string.IsNullOrEmpty(Filter)) - query += $"?q={Uri.EscapeDataString(Filter)}"; - - IEnumerable selectedMilestones = _milestones.Where(m => m.Value).Select(kv => kv.Key); - if (selectedMilestones.Count() != _milestones.Count) - { - foreach (string m in selectedMilestones) - query += $"&m={Uri.EscapeDataString(m)}"; - } - - string uri = new UriBuilder(NavigationManager.Uri) - { - Query = query - }.ToString(); - - await JSRuntime.InvokeVoidAsync("Blazor.navigateTo", - uri.ToString(), - /* forceLoad */ false, - /* replace */ true); + string path = NavigationManager.ToAbsoluteUri(NavigationManager.Uri).AbsolutePath; + var parts = new List(); + if (_selectedGroup != RepositoryGroupService.Default) + parts.Add($"g={Uri.EscapeDataString(_selectedGroup.Name)}"); + if (!string.IsNullOrEmpty(_filter)) + parts.Add($"q={Uri.EscapeDataString(_filter)}"); + if (extraParam != null) + parts.Add(extraParam); + return parts.Count > 0 ? $"{path}?{string.Join("&", parts)}" : path; } private void LoadData() { _milestones = CreateMilestones(Issues, _milestones); - _checkedIssues.Clear(); } private async void IssuesChanged(object? sender, EventArgs e) @@ -202,91 +174,4 @@ private SortedDictionary CreateMilestones(IReadOnlyList@{reviewer.Name}"); - } - } - - sb.AppendLine(); - } - } - - return sb.ToString(); - } - - private void CheckAllIssues(bool value) - { - if (value) - _checkedIssues.UnionWith(VisibleIssues); - else - _checkedIssues.ExceptWith(VisibleIssues); - } - - private void CheckIssue(ApiReviewIssue issue, bool value) - { - if (value) - _checkedIssues.Add(issue); - else - _checkedIssues.Remove(issue); - } - - public bool CanRefresh => _refresh is null; - - public Task RefreshAsync() - { - return (_refresh = RefreshAsyncCore()); - - async Task RefreshAsyncCore() - { - await IssueService.ReloadAsync(); - _refresh = null; - } - } } diff --git a/src/apireview.net/Pages/Publish.razor b/src/apireview.net/Pages/Publish.razor index a08ff32..a5ffcbe 100644 --- a/src/apireview.net/Pages/Publish.razor +++ b/src/apireview.net/Pages/Publish.razor @@ -1,4 +1,5 @@ @page "/publish" +@rendermode InteractiveServer
diff --git a/src/apireview.net/Pages/Schedule.razor b/src/apireview.net/Pages/Schedule.razor index d59a4f0..2e2e0c2 100644 --- a/src/apireview.net/Pages/Schedule.razor +++ b/src/apireview.net/Pages/Schedule.razor @@ -1,17 +1,17 @@ @page "/schedule" @using ApiReviewDotNet.Services.Calendar -@if (CurrentDate is not null) +@if (Cells.Length > 0) {
- - - -

@CurrentDate.Value.ToString("MMMM yyyy")

+ + Today + +

@CurrentDate.ToString("MMMM yyyy")

} -@if (Cells is not null) +@if (Cells.Length > 0) {
Sunday
@@ -31,7 +31,7 @@ ? @cell.DateTime.ToString("MMM d") : @cell.DateTime.Day.ToString(); -
+
@dayText
@if (cell.Entries.Any()) @@ -39,7 +39,7 @@
    @foreach (CalendarEntry entry in cell.Entries) { -
  • @entry.Start.ToString("t") @entry.Title
  • +
  • @entry.Title
  • }
} @@ -52,3 +52,24 @@ Videos are streamed to the .NET Foundation YouTube channel. This calendar is available as an .ics file you can add to your favorite calendar app.

+ + diff --git a/src/apireview.net/Pages/Schedule.razor.cs b/src/apireview.net/Pages/Schedule.razor.cs index 31cb059..2f21270 100644 --- a/src/apireview.net/Pages/Schedule.razor.cs +++ b/src/apireview.net/Pages/Schedule.razor.cs @@ -1,5 +1,4 @@  -using ApiReviewDotNet.Data; using ApiReviewDotNet.Services.Calendar; using Microsoft.AspNetCore.Components; @@ -8,59 +7,34 @@ namespace ApiReviewDotNet.Pages; public sealed partial class Schedule { - private DateTimeOffset? Today { get; set; } - private DateTimeOffset? CurrentDate { get; set; } + private static readonly TimeZoneInfo PacificTime = TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles"); - private CalendarCell[] Cells { get; set; } = Array.Empty(); + [SupplyParameterFromQuery(Name = "month")] + public string? MonthParam { get; set; } - [Inject] - public TimeZoneService TimeZoneService { get; set; } = null!; + private DateTimeOffset Today { get; set; } + private DateTimeOffset CurrentDate { get; set; } + private CalendarCell[] Cells { get; set; } = Array.Empty(); [Inject] public CalendarService CalendarService { get; set; } = null!; - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override async Task OnInitializedAsync() { - if (firstRender) - await UpdateCellsAsync(); - } + DateTimeOffset nowPacific = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, PacificTime); + Today = new DateTimeOffset(nowPacific.Year, nowPacific.Month, nowPacific.Day, 0, 0, 0, nowPacific.Offset); - private async Task UpdateCellsAsync() - { - if (Today is null) + if (!string.IsNullOrEmpty(MonthParam) && + DateTime.TryParseExact(MonthParam, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out DateTime parsed)) { - DateTimeOffset userDateTime = await TimeZoneService.ToLocalAsync(DateTime.UtcNow); - Today = new DateTimeOffset(userDateTime.Year, userDateTime.Month, userDateTime.Day, 0, 0, 0, 0, userDateTime.Offset); + TimeSpan offset = PacificTime.GetUtcOffset(new DateTime(parsed.Year, parsed.Month, 1)); + CurrentDate = new DateTimeOffset(parsed.Year, parsed.Month, 1, 0, 0, 0, offset); } - - if (CurrentDate is null) - CurrentDate = Today; - - Cells = (await CalendarService.GetCellsAsync(CurrentDate.Value)).ToArray(); - StateHasChanged(); - } - - private async Task TodayAsync() - { - CurrentDate = null; - await UpdateCellsAsync(); - } - - private async Task PreviousMonthAsync() - { - if (CurrentDate is not null) + else { - CurrentDate = CurrentDate.Value.AddMonths(-1); - await UpdateCellsAsync(); + CurrentDate = Today; } - } - private async Task NextMonthAsync() - { - if (CurrentDate is not null) - { - CurrentDate = CurrentDate.Value.AddMonths(1); - await UpdateCellsAsync(); - } + Cells = (await CalendarService.GetCellsAsync(CurrentDate)).ToArray(); } } diff --git a/src/apireview.net/Program.cs b/src/apireview.net/Program.cs index fef57a4..2d9526f 100644 --- a/src/apireview.net/Program.cs +++ b/src/apireview.net/Program.cs @@ -20,7 +20,8 @@ { o.JsonSerializerOptions.Converters.Add(new TimeSpanJsonConverter()); }); -builder.Services.AddRazorComponents(); +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddControllers(); builder.Services.AddSingleton(); @@ -44,7 +45,6 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddAuthentication(options => { @@ -108,6 +108,7 @@ app.MapDefaultControllerRoute(); app.MapGitHubWebhooks(); -app.MapRazorComponents(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); app.Run(); diff --git a/src/apireview.net/Shared/MainLayout.razor b/src/apireview.net/Shared/MainLayout.razor index 1ac30ca..c765308 100644 --- a/src/apireview.net/Shared/MainLayout.razor +++ b/src/apireview.net/Shared/MainLayout.razor @@ -3,10 +3,10 @@