From b1767a7920fe2fa647f963d5ba3e04161755cdcf Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 15:50:54 +0200 Subject: [PATCH 01/14] Make tabs clearly visible --- src/Client/Pages/Admin/AdminPage.razor | 43 ++++++++++++---------- src/Client/Pages/Admin/AdminPage.razor.css | 4 ++ 2 files changed, 28 insertions(+), 19 deletions(-) create mode 100644 src/Client/Pages/Admin/AdminPage.razor.css diff --git a/src/Client/Pages/Admin/AdminPage.razor b/src/Client/Pages/Admin/AdminPage.razor index 3f23c206..dcabdccb 100644 --- a/src/Client/Pages/Admin/AdminPage.razor +++ b/src/Client/Pages/Admin/AdminPage.razor @@ -4,25 +4,30 @@

Adminsida

- - - Hantera kontrakt - Hantera användare - - - - - - - - - - - - + +
+ + + Hantera kontrakt + Hantera användare + + + + + + + + + + + + +
@code { diff --git a/src/Client/Pages/Admin/AdminPage.razor.css b/src/Client/Pages/Admin/AdminPage.razor.css new file mode 100644 index 00000000..86ba7676 --- /dev/null +++ b/src/Client/Pages/Admin/AdminPage.razor.css @@ -0,0 +1,4 @@ +::deep .nav-link { + background: #e7e7e7; + color: var(--prodigo-dark-blue) +} From 0775007870487e80a87afb520fdd5df6d7317f96 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 16:04:38 +0200 Subject: [PATCH 02/14] Add edit button to contracts --- src/Client/Pages/Admin/AdminPage.razor | 8 ++++- src/Client/Pages/Admin/ContractTable.razor | 13 ++++++-- src/Client/Pages/Admin/ContractTableRow.razor | 30 ++++++++++++++----- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/Client/Pages/Admin/AdminPage.razor b/src/Client/Pages/Admin/AdminPage.razor index dcabdccb..d114b583 100644 --- a/src/Client/Pages/Admin/AdminPage.razor +++ b/src/Client/Pages/Admin/AdminPage.razor @@ -19,7 +19,7 @@ - + @@ -51,4 +51,10 @@ _selectedTab = tabName; return Task.CompletedTask; } + + private void OnContractOpeningForEdit(Contract contract) + { + Console.WriteLine("Contract being edited " + contract.Name); + } + } diff --git a/src/Client/Pages/Admin/ContractTable.razor b/src/Client/Pages/Admin/ContractTable.razor index f9270292..a5bcbe35 100644 --- a/src/Client/Pages/Admin/ContractTable.razor +++ b/src/Client/Pages/Admin/ContractTable.razor @@ -12,6 +12,7 @@ Kontraktnamn Företag + Redigera Ta bort @@ -19,7 +20,9 @@ @foreach (Contract contract in contracts) { - + } @@ -28,7 +31,11 @@ @code { - private FetchData> _dataFetcher = null!; + /// + /// Called when a is being opened to be edited. + /// + [Parameter] + public EventCallback ContractOpeningForEdit { get; set; } = EventCallback.Empty; /// /// Adds a contract to the list. @@ -40,6 +47,8 @@ InvokeAsync(StateHasChanged); } + private FetchData> _dataFetcher = null!; + /// /// Removes a contract from the list. /// diff --git a/src/Client/Pages/Admin/ContractTableRow.razor b/src/Client/Pages/Admin/ContractTableRow.razor index e8dc756a..2ba10805 100644 --- a/src/Client/Pages/Admin/ContractTableRow.razor +++ b/src/Client/Pages/Admin/ContractTableRow.razor @@ -7,29 +7,45 @@ @Contract.SupplierName + + + + - - + @code { + /// /// Called when a contract has been removed successfully. /// [Parameter] public EventCallback OnContractRemoved { get; set; } = EventCallback.Empty; + /// + /// Called when a is being opened to be edited. + /// + [Parameter] + public EventCallback ContractOpeningForEdit { get; set; } = EventCallback.Empty; /// /// The contract. From 7a57537a7139279bfdd37f2495d69446519a9c1b Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 16:21:00 +0200 Subject: [PATCH 03/14] Fill in contract form with data and scroll to it --- src/Client/Pages/Admin/AdminPage.razor | 8 +++++--- src/Client/Pages/Admin/ContractForm.razor | 15 ++++++++++++++- src/Client/wwwroot/js/interop.js | 4 ++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/Client/Pages/Admin/AdminPage.razor b/src/Client/Pages/Admin/AdminPage.razor index d114b583..3a8e35fc 100644 --- a/src/Client/Pages/Admin/AdminPage.razor +++ b/src/Client/Pages/Admin/AdminPage.razor @@ -18,7 +18,7 @@ - + @@ -33,8 +33,10 @@ private string _selectedTab = "contracts"; + // Null-forgiving operator used because these fields are set by the Blazor runtime using @ref private ContractTable _contractTable = null!; private UserTable _userTable = null!; + private ContractForm _contractForm = null!; private void AddContractToTable(Contract contract) { @@ -52,9 +54,9 @@ return Task.CompletedTask; } - private void OnContractOpeningForEdit(Contract contract) + private async Task OnContractOpeningForEdit(Contract contract) { - Console.WriteLine("Contract being edited " + contract.Name); + await _contractForm.EditContractAsync(contract); } } diff --git a/src/Client/Pages/Admin/ContractForm.razor b/src/Client/Pages/Admin/ContractForm.razor index d67ec07f..ca4990ff 100644 --- a/src/Client/Pages/Admin/ContractForm.razor +++ b/src/Client/Pages/Admin/ContractForm.razor @@ -6,8 +6,9 @@ @using Blazorise.Markdown @inject ILogger _logger @inject HttpClient _http +@inject IJSRuntime _js -

Skapa nytt kontrakt

+

Skapa nytt kontrakt

@@ -200,4 +201,16 @@ } } + + /// + /// Populates the form with the values of the given . + /// + /// The data to edit. + public async Task EditContractAsync(Contract contract) + { + await _js.InvokeVoidAsync("scrollToElement", "#contract-form-title"); + _shouldRender = true; + _contract = contract; + } + } diff --git a/src/Client/wwwroot/js/interop.js b/src/Client/wwwroot/js/interop.js index 520cb582..da7d423a 100644 --- a/src/Client/wwwroot/js/interop.js +++ b/src/Client/wwwroot/js/interop.js @@ -8,3 +8,7 @@ function focusElement(selector) { const element = document.querySelector(selector); element.focus(); } + +function scrollToElement(selector) { + document.querySelector(selector).scrollIntoView({behavior: 'smooth', block: 'start'}); +} From 42c5c766378af4d5fe0036b1dec25850c61665ab Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 16:24:29 +0200 Subject: [PATCH 04/14] Move public method to top --- src/Client/Pages/Admin/ContractForm.razor | 24 +++++++++++------------ 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/Client/Pages/Admin/ContractForm.razor b/src/Client/Pages/Admin/ContractForm.razor index ca4990ff..8c144ac6 100644 --- a/src/Client/Pages/Admin/ContractForm.razor +++ b/src/Client/Pages/Admin/ContractForm.razor @@ -102,6 +102,17 @@ private const string MinFormHeight = "5rem"; + /// + /// Populates the form with the values of the given . + /// + /// The data to edit. + public async Task EditContractAsync(Contract contract) + { + await _js.InvokeVoidAsync("scrollToElement", "#contract-form-title"); + _shouldRender = true; + _contract = contract; + } + private static Contract CreateEmptyContract() { return new Contract @@ -200,17 +211,4 @@ _contract = CreateEmptyContract(); } } - - - /// - /// Populates the form with the values of the given . - /// - /// The data to edit. - public async Task EditContractAsync(Contract contract) - { - await _js.InvokeVoidAsync("scrollToElement", "#contract-form-title"); - _shouldRender = true; - _contract = contract; - } - } From 9d6cc1f671eff957e6dbe1837eb704084d955e86 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 16:34:05 +0200 Subject: [PATCH 05/14] Refactor file upload methods --- src/Client/Pages/Admin/ContractForm.razor | 32 +++++++++++++---------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/Client/Pages/Admin/ContractForm.razor b/src/Client/Pages/Admin/ContractForm.razor index 8c144ac6..7b8b1d69 100644 --- a/src/Client/Pages/Admin/ContractForm.razor +++ b/src/Client/Pages/Admin/ContractForm.razor @@ -138,34 +138,38 @@ { _shouldRender = false; - content = new MultipartFormDataContent(); - StreamContent fileContent; - const long bitsInAKilobyte = 1024; const long kilobytesInAMegabyte = 1024; const long maxFileSize = bitsInAKilobyte * kilobytesInAMegabyte * 100; + Stream fileStream = arg.File.OpenReadStream(maxFileSize); + + content = CreateFormDataContent(fileStream, arg.File.Name, arg.File.ContentType); + + _shouldRender = true; + } + + private MultipartFormDataContent CreateFormDataContent(Stream fileStream, string fileName, string contentType) + { + StreamContent fileContent; + try { - fileContent = new StreamContent(arg.File.OpenReadStream(maxFileSize)); + fileContent = new StreamContent(fileStream); } catch (IOException ex) { - _logger.LogInformation( - "{FileName} not uploaded: {Message}", - arg.File.Name, ex.Message); - return; + _logger.LogInformation("{FileName} not uploaded: {Message}", fileName, ex.Message); + throw; } fileContent.Headers.ContentType = - new MediaTypeHeaderValue(arg.File.ContentType); + new MediaTypeHeaderValue(contentType); - content.Add( - fileContent, - "\"file\"", - arg.File.Name); + var content = new MultipartFormDataContent(); + content.Add(fileContent, "\"file\"", fileName); - _shouldRender = true; + return content; } private async Task OnSubmit() From 64994f092c82464104c4f7d2599dc918ac0dceea Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 17:12:58 +0200 Subject: [PATCH 06/14] Download contract files when editing them --- src/Client/Pages/Admin/ContractForm.razor | 24 +++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Client/Pages/Admin/ContractForm.razor b/src/Client/Pages/Admin/ContractForm.razor index 7b8b1d69..e85a5a3a 100644 --- a/src/Client/Pages/Admin/ContractForm.razor +++ b/src/Client/Pages/Admin/ContractForm.razor @@ -111,6 +111,27 @@ await _js.InvokeVoidAsync("scrollToElement", "#contract-form-title"); _shouldRender = true; _contract = contract; + await DownloadContractFiles(); + } + + private async Task DownloadContractFiles() + { + await DownloadContractFile(_contract.InspirationalImagePath, content => _inspirationalContent = content); + await DownloadContractFile(_contract.SupplierLogoImagePath, content => _supplierLogoContent = content); + await DownloadContractFile(_contract.AdditionalDocument, content => _additionalDocumentContent = content); + } + + private async Task DownloadContractFile(string path, Action setContent) + { + + var fileUri = new Uri(Path.Join(_http.BaseAddress?.ToString(), path[1..]), UriKind.Absolute); + + HttpResponseMessage response = await _http.GetAsync(fileUri); + string mediaType = response.Content.Headers.ContentType?.MediaType ?? "images/jpeg"; + Stream stream = await response.Content.ReadAsStreamAsync(); + + MultipartFormDataContent content = CreateFormDataContent(stream, fileUri.Segments.Last(), mediaType); + setContent(content); } private static Contract CreateEmptyContract() @@ -163,8 +184,7 @@ throw; } - fileContent.Headers.ContentType = - new MediaTypeHeaderValue(contentType); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(contentType); var content = new MultipartFormDataContent(); content.Add(fileContent, "\"file\"", fileName); From 48e32bdb8117db2cc1d2377d529ca6e23d9501d3 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 17:23:47 +0200 Subject: [PATCH 07/14] Refactor integration tests to common base class --- .../Authentication/AuthIntegrationTests.cs | 73 ++++--------------- .../Contracts/ContractApiIntegrationTests.cs | 15 ++++ .../IntegrationTest.cs | 67 +++++++++++++++++ 3 files changed, 98 insertions(+), 57 deletions(-) create mode 100644 tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs create mode 100644 tests/Server.Tests.Integration/IntegrationTest.cs diff --git a/tests/Server.Tests.Integration/Authentication/AuthIntegrationTests.cs b/tests/Server.Tests.Integration/Authentication/AuthIntegrationTests.cs index 787b5c68..72f84e1a 100644 --- a/tests/Server.Tests.Integration/Authentication/AuthIntegrationTests.cs +++ b/tests/Server.Tests.Integration/Authentication/AuthIntegrationTests.cs @@ -3,27 +3,23 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Threading.Tasks; + using Application.Configuration; using Application.Contracts; using Application.Users; + using Domain.Contracts; using Domain.Users; + using FluentAssertions.Execution; namespace Server.IntegrationTests.Authentication; -public class AuthIntegrationTests : IClassFixture +public class AuthIntegrationTests : IntegrationTest { - private readonly HttpClient _client; - public AuthIntegrationTests(TestWebApplicationFactory factory) + : base(factory) { - _client = factory.CreateClient(); - - Environment.SetEnvironmentVariable( - ConfigurationKeys.AdminPassword, - "test_password", - EnvironmentVariableTarget.Process); } public static IEnumerable AdminPostApiEndpoints => @@ -61,7 +57,7 @@ public async Task GetApiEndpoints_ReturnsUnauthorized_WhenNoTokenIsSpecifiedAsyn // Arrange // Act - HttpResponseMessage response = await _client.GetAsync(endpointUrl); + HttpResponseMessage response = await Client.GetAsync(endpointUrl); // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); @@ -76,7 +72,7 @@ public async Task SendToAdminPostApiEndpoints_ReturnsUnauthorized_WhenTokenIsNot // Arrange // Act - HttpResponseMessage response = await _client.PostAsync(endpointUrl, content); + HttpResponseMessage response = await Client.PostAsync(endpointUrl, content); // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); @@ -92,7 +88,7 @@ public async Task SendToAdminPostApiEndpoints_ReturnsForbidden_WhenUserTokenIsSp await ArrangeAuthenticatedUser(); // Act - HttpResponseMessage response = await _client.PostAsync(endpointUrl, content); + HttpResponseMessage response = await Client.PostAsync(endpointUrl, content); // Assert response.StatusCode.Should().Be(HttpStatusCode.Forbidden); @@ -108,7 +104,7 @@ public async Task SendToAdminPostApiEndpoints_IsSuccessful_WhenAdminIsAuthentica await ArrangeAuthenticatedAdmin(); // Act - HttpResponseMessage response = await _client.PostAsync(endpointUrl, content); + HttpResponseMessage response = await Client.PostAsync(endpointUrl, content); // Assert response.Should().BeSuccessful(); @@ -122,10 +118,10 @@ public async Task SendToAdminDeleteApiEndpoints_ReturnsForbidden_WhenUserTokenIs { // Arrange await ArrangeAuthenticatedUser(); - await createResource(_client); + await createResource(Client); // Act - HttpResponseMessage response = await _client.DeleteAsync(endpointUrl); + HttpResponseMessage response = await Client.DeleteAsync(endpointUrl); // Assert response.StatusCode.Should().Be(HttpStatusCode.Forbidden); @@ -139,10 +135,10 @@ public async Task SendToAdminDeleteApiEndpoints_IsSuccessful_WhenAdminIsAuthenti { // Arrange await ArrangeAuthenticatedAdmin(); - await createResource(_client); + await createResource(Client); // Act - HttpResponseMessage response = await _client.DeleteAsync(endpointUrl); + HttpResponseMessage response = await Client.DeleteAsync(endpointUrl); // Assert response.Should().BeSuccessful(); @@ -156,7 +152,7 @@ public async Task GetContractsApiEndpoint_ReturnsPreviewContent_WhenUserIsNotAut await ArrangeResource("/api/v1/contracts", contract); // Act - HttpResponseMessage response = await _client.GetAsync("/api/v1/contracts"); + HttpResponseMessage response = await Client.GetAsync("/api/v1/contracts"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -183,7 +179,7 @@ public async Task GetContractsApiEndpoint_DoesNotReturnConfidentialContent_WhenU await ArrangeResource("/api/v1/contracts", contract); // Act - HttpResponseMessage response = await _client.GetAsync("/api/v1/contracts"); + HttpResponseMessage response = await Client.GetAsync("/api/v1/contracts"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -202,48 +198,11 @@ public async Task GetContractsApiEndpoint_ReturnsOkContent_WhenUserIsAuthenticat await ArrangeAuthenticatedUser(); // Act - HttpResponseMessage response = await _client.GetAsync("/api/v1/contracts"); + HttpResponseMessage response = await Client.GetAsync("/api/v1/contracts"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var contracts = await response.Content.ReadFromJsonAsync>(); contracts.Should().ContainEquivalentOf(contract); } - - private async Task ArrangeResource(string url, TResource resource) - { - await ArrangeAuthenticatedAdmin(); - await _client.PostAsJsonAsync(url, resource); - - // Log out. - _client.DefaultRequestHeaders.Authorization = null; - } - - private async Task ArrangeAuthenticatedAdmin() - { - var userInfo = new User() { Name = "admin", Password = Environment.GetEnvironmentVariable(ConfigurationKeys.AdminPassword) ?? string.Empty, }; - HttpResponseMessage authResponseMessage = await _client.PostAsJsonAsync("/api/v1/users/authenticate", userInfo); - AuthenticateResponse? authResponse = - await authResponseMessage.Content.ReadFromJsonAsync(); - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", authResponse?.Token); - } - - private async Task ArrangeAuthenticatedUser() - { - // Arrange - authenticate as admin user. - await ArrangeAuthenticatedAdmin(); - - // Arrange - create normal user. - var user = new User(); - await _client.PostAsJsonAsync("/api/v1/users", user); - - // Arrange - authenticate as normal user. - HttpResponseMessage authResponseMessage = - await _client.PostAsJsonAsync("/api/v1/users/authenticate", user); - AuthenticateResponse? authResponse = - await authResponseMessage.Content.ReadFromJsonAsync(); - - // Swap out the admin token for a normal user token. - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", authResponse?.Token); - } } diff --git a/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs b/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs new file mode 100644 index 00000000..6ae3b761 --- /dev/null +++ b/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs @@ -0,0 +1,15 @@ +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; + +using Domain.Contracts; + +namespace Server.IntegrationTests.Contracts; + +public class ContractApiIntegrationTests : IntegrationTest +{ + public ContractApiIntegrationTests(TestWebApplicationFactory factory) + : base(factory) + { + } +} diff --git a/tests/Server.Tests.Integration/IntegrationTest.cs b/tests/Server.Tests.Integration/IntegrationTest.cs new file mode 100644 index 00000000..8c895d6b --- /dev/null +++ b/tests/Server.Tests.Integration/IntegrationTest.cs @@ -0,0 +1,67 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Threading.Tasks; + +using Application.Configuration; +using Application.Users; + +using Domain.Users; + +namespace Server.IntegrationTests; + +public class IntegrationTest : IClassFixture +{ + public IntegrationTest(TestWebApplicationFactory factory) + { + Client = factory.CreateClient(); + + Environment.SetEnvironmentVariable( + ConfigurationKeys.AdminPassword, + "test_password", + EnvironmentVariableTarget.Process); + } + + protected HttpClient Client { get; } + + protected async Task ArrangeResource(string url, TResource resource) + { + await ArrangeAuthenticatedAdmin(); + await Client.PostAsJsonAsync(url, resource); + + // Log out. + Client.DefaultRequestHeaders.Authorization = null; + } + + protected async Task ArrangeAuthenticatedAdmin() + { + var userInfo = new User() + { + Name = "admin", + Password = Environment.GetEnvironmentVariable(ConfigurationKeys.AdminPassword) ?? string.Empty, + }; + HttpResponseMessage authResponseMessage = await Client.PostAsJsonAsync("/api/v1/users/authenticate", userInfo); + AuthenticateResponse? authResponse = + await authResponseMessage.Content.ReadFromJsonAsync(); + Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", authResponse?.Token); + } + + protected async Task ArrangeAuthenticatedUser() + { + // Arrange - authenticate as admin user. + await ArrangeAuthenticatedAdmin(); + + // Arrange - create normal user. + var user = new User(); + await Client.PostAsJsonAsync("/api/v1/users", user); + + // Arrange - authenticate as normal user. + HttpResponseMessage authResponseMessage = + await Client.PostAsJsonAsync("/api/v1/users/authenticate", user); + AuthenticateResponse? authResponse = + await authResponseMessage.Content.ReadFromJsonAsync(); + + // Swap out the admin token for a normal user token. + Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", authResponse?.Token); + } +} From 2268fc3cbbab05d6a5df41ab88db583f89195522 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 17:24:28 +0200 Subject: [PATCH 08/14] Rename methods with async suffix --- .../Authentication/AuthIntegrationTests.cs | 16 ++++++++-------- .../Contracts/ContractApiIntegrationTests.cs | 14 ++++++++++++++ .../Server.Tests.Integration/IntegrationTest.cs | 10 +++++----- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/tests/Server.Tests.Integration/Authentication/AuthIntegrationTests.cs b/tests/Server.Tests.Integration/Authentication/AuthIntegrationTests.cs index 72f84e1a..2704eed6 100644 --- a/tests/Server.Tests.Integration/Authentication/AuthIntegrationTests.cs +++ b/tests/Server.Tests.Integration/Authentication/AuthIntegrationTests.cs @@ -85,7 +85,7 @@ public async Task SendToAdminPostApiEndpoints_ReturnsForbidden_WhenUserTokenIsSp HttpContent content) { // Arrange - await ArrangeAuthenticatedUser(); + await ArrangeAuthenticatedUserAsync(); // Act HttpResponseMessage response = await Client.PostAsync(endpointUrl, content); @@ -101,7 +101,7 @@ public async Task SendToAdminPostApiEndpoints_IsSuccessful_WhenAdminIsAuthentica HttpContent content) { // Arrange - await ArrangeAuthenticatedAdmin(); + await ArrangeAuthenticatedAdminAsync(); // Act HttpResponseMessage response = await Client.PostAsync(endpointUrl, content); @@ -117,7 +117,7 @@ public async Task SendToAdminDeleteApiEndpoints_ReturnsForbidden_WhenUserTokenIs Func createResource) { // Arrange - await ArrangeAuthenticatedUser(); + await ArrangeAuthenticatedUserAsync(); await createResource(Client); // Act @@ -134,7 +134,7 @@ public async Task SendToAdminDeleteApiEndpoints_IsSuccessful_WhenAdminIsAuthenti Func createResource) { // Arrange - await ArrangeAuthenticatedAdmin(); + await ArrangeAuthenticatedAdminAsync(); await createResource(Client); // Act @@ -149,7 +149,7 @@ public async Task GetContractsApiEndpoint_ReturnsPreviewContent_WhenUserIsNotAut { // Arrange var contract = new Contract(); - await ArrangeResource("/api/v1/contracts", contract); + await ArrangeResourceAsync("/api/v1/contracts", contract); // Act HttpResponseMessage response = await Client.GetAsync("/api/v1/contracts"); @@ -176,7 +176,7 @@ public async Task GetContractsApiEndpoint_DoesNotReturnConfidentialContent_WhenU { // Arrange var contract = new Contract { Instructions = "very secret usage instructions", }; - await ArrangeResource("/api/v1/contracts", contract); + await ArrangeResourceAsync("/api/v1/contracts", contract); // Act HttpResponseMessage response = await Client.GetAsync("/api/v1/contracts"); @@ -193,9 +193,9 @@ public async Task GetContractsApiEndpoint_ReturnsOkContent_WhenUserIsAuthenticat { // Arrange var contract = new Contract(); - await ArrangeResource("/api/v1/contracts", contract); + await ArrangeResourceAsync("/api/v1/contracts", contract); - await ArrangeAuthenticatedUser(); + await ArrangeAuthenticatedUserAsync(); // Act HttpResponseMessage response = await Client.GetAsync("/api/v1/contracts"); diff --git a/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs b/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs index 6ae3b761..095c7fa9 100644 --- a/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs +++ b/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs @@ -12,4 +12,18 @@ public ContractApiIntegrationTests(TestWebApplicationFactory factory) : base(factory) { } + + [Fact] + public async Task PutContract_CreatesNewContract_IfItDoesNotExist() + { + // Arrange + await ArrangeAuthenticatedAdminAsync(); + var newContract = new Contract(); + + // Act + HttpResponseMessage response = await Client.PutAsJsonAsync("api/v1/contracts", newContract); + + // Assert + response.Should().BeSuccessful(); + } } diff --git a/tests/Server.Tests.Integration/IntegrationTest.cs b/tests/Server.Tests.Integration/IntegrationTest.cs index 8c895d6b..c1516cb7 100644 --- a/tests/Server.Tests.Integration/IntegrationTest.cs +++ b/tests/Server.Tests.Integration/IntegrationTest.cs @@ -24,16 +24,16 @@ public IntegrationTest(TestWebApplicationFactory factory) protected HttpClient Client { get; } - protected async Task ArrangeResource(string url, TResource resource) + protected async Task ArrangeResourceAsync(string url, TResource resource) { - await ArrangeAuthenticatedAdmin(); + await ArrangeAuthenticatedAdminAsync(); await Client.PostAsJsonAsync(url, resource); // Log out. Client.DefaultRequestHeaders.Authorization = null; } - protected async Task ArrangeAuthenticatedAdmin() + protected async Task ArrangeAuthenticatedAdminAsync() { var userInfo = new User() { @@ -46,10 +46,10 @@ protected async Task ArrangeAuthenticatedAdmin() Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", authResponse?.Token); } - protected async Task ArrangeAuthenticatedUser() + protected async Task ArrangeAuthenticatedUserAsync() { // Arrange - authenticate as admin user. - await ArrangeAuthenticatedAdmin(); + await ArrangeAuthenticatedAdminAsync(); // Arrange - create normal user. var user = new User(); From e6ff2e6790395d596ed3a0677362628e95f910ef Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 17:30:35 +0200 Subject: [PATCH 09/14] Change POST /contracts to PUT /contracts --- src/Client/Pages/Admin/ContractForm.razor | 2 +- src/Server/Controllers/ContractsController.cs | 7 ++++--- .../Authentication/AuthIntegrationTests.cs | 16 ++++++---------- .../Contracts/ContractApiIntegrationTests.cs | 4 +++- .../Server.Tests.Integration/IntegrationTest.cs | 11 ++++++++++- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/Client/Pages/Admin/ContractForm.razor b/src/Client/Pages/Admin/ContractForm.razor index e85a5a3a..be9f2894 100644 --- a/src/Client/Pages/Admin/ContractForm.razor +++ b/src/Client/Pages/Admin/ContractForm.razor @@ -228,7 +228,7 @@ { string json = JsonConvert.SerializeObject(_contract); var content = new StringContent(json, Encoding.UTF8, "application/json"); - HttpResponseMessage response = await _http.PostAsync("/api/v1/contracts", content); + HttpResponseMessage response = await _http.PutAsync($"/api/v1/contracts/{_contract.Id}", content); if (response.IsSuccessStatusCode) { await OnContractUploaded.InvokeAsync(_contract); diff --git a/src/Server/Controllers/ContractsController.cs b/src/Server/Controllers/ContractsController.cs index 5885a6e1..1bb7c21f 100644 --- a/src/Server/Controllers/ContractsController.cs +++ b/src/Server/Controllers/ContractsController.cs @@ -82,13 +82,14 @@ public IActionResult AddRecent(Contract contract) /// /// Creates a new contract. /// - /// The contract to add. + /// The contract to put. + /// The identifier of the contract to put. /// The identifier of the stored image. /// The ID of the contract was already taken. - [HttpPost] + [HttpPut("{id:guid}")] [Authorize("AdminOnly")] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public IActionResult CreateContract(Contract contract) + public IActionResult CreateContract([FromBody] Contract contract, Guid id) { try { diff --git a/tests/Server.Tests.Integration/Authentication/AuthIntegrationTests.cs b/tests/Server.Tests.Integration/Authentication/AuthIntegrationTests.cs index 2704eed6..0903f328 100644 --- a/tests/Server.Tests.Integration/Authentication/AuthIntegrationTests.cs +++ b/tests/Server.Tests.Integration/Authentication/AuthIntegrationTests.cs @@ -1,12 +1,9 @@ using System.Net; using System.Net.Http; -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Threading.Tasks; -using Application.Configuration; using Application.Contracts; -using Application.Users; using Domain.Contracts; using Domain.Users; @@ -25,7 +22,6 @@ public AuthIntegrationTests(TestWebApplicationFactory factory) public static IEnumerable AdminPostApiEndpoints => new List { - new object[] { "/api/v1/contracts", JsonContent.Create(new Contract()), }, new object[] { "/api/v1/users", JsonContent.Create(new User()), }, }; @@ -33,10 +29,10 @@ public static IEnumerable AdminDeleteApiEndpoints { get { - const string contractEndpoint = "/api/v1/contracts"; var contract = new Contract(); + string contractEndpoint = $"/api/v1/contracts/{contract.Id}"; Func createContract = - async client => await client.PostAsJsonAsync(contractEndpoint, contract); + async client => await client.PutAsJsonAsync(contractEndpoint, contract); const string usersEndpoint = "/api/v1/users"; var user = new User(); @@ -44,7 +40,7 @@ public static IEnumerable AdminDeleteApiEndpoints return new List { - new object[] { contractEndpoint + $"/{contract.Id}", createContract, }, + new object[] { contractEndpoint, createContract, }, new object[] { usersEndpoint + $"/{user.Id}", createUser, }, }; } @@ -149,7 +145,7 @@ public async Task GetContractsApiEndpoint_ReturnsPreviewContent_WhenUserIsNotAut { // Arrange var contract = new Contract(); - await ArrangeResourceAsync("/api/v1/contracts", contract); + await PutResourceAsync($"/api/v1/contracts/{contract.Id}", contract); // Act HttpResponseMessage response = await Client.GetAsync("/api/v1/contracts"); @@ -176,7 +172,7 @@ public async Task GetContractsApiEndpoint_DoesNotReturnConfidentialContent_WhenU { // Arrange var contract = new Contract { Instructions = "very secret usage instructions", }; - await ArrangeResourceAsync("/api/v1/contracts", contract); + await PutResourceAsync("/api/v1/contracts", contract); // Act HttpResponseMessage response = await Client.GetAsync("/api/v1/contracts"); @@ -193,7 +189,7 @@ public async Task GetContractsApiEndpoint_ReturnsOkContent_WhenUserIsAuthenticat { // Arrange var contract = new Contract(); - await ArrangeResourceAsync("/api/v1/contracts", contract); + await PutResourceAsync($"/api/v1/contracts/{contract.Id}", contract); await ArrangeAuthenticatedUserAsync(); diff --git a/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs b/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs index 095c7fa9..356c237c 100644 --- a/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs +++ b/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs @@ -21,9 +21,11 @@ public async Task PutContract_CreatesNewContract_IfItDoesNotExist() var newContract = new Contract(); // Act - HttpResponseMessage response = await Client.PutAsJsonAsync("api/v1/contracts", newContract); + HttpResponseMessage response = await Client.PutAsJsonAsync($"api/v1/contracts/{newContract.Id}", newContract); // Assert response.Should().BeSuccessful(); + var contracts = await Client.GetFromJsonAsync>("api/v1/contracts"); + contracts.Should().ContainEquivalentOf(newContract); } } diff --git a/tests/Server.Tests.Integration/IntegrationTest.cs b/tests/Server.Tests.Integration/IntegrationTest.cs index c1516cb7..cf2ab3be 100644 --- a/tests/Server.Tests.Integration/IntegrationTest.cs +++ b/tests/Server.Tests.Integration/IntegrationTest.cs @@ -24,7 +24,7 @@ public IntegrationTest(TestWebApplicationFactory factory) protected HttpClient Client { get; } - protected async Task ArrangeResourceAsync(string url, TResource resource) + protected async Task PostResourceAsync(string url, TResource resource) { await ArrangeAuthenticatedAdminAsync(); await Client.PostAsJsonAsync(url, resource); @@ -33,6 +33,15 @@ protected async Task ArrangeResourceAsync(string url, TResource resou Client.DefaultRequestHeaders.Authorization = null; } + protected async Task PutResourceAsync(string url, TResource resource) + { + await ArrangeAuthenticatedAdminAsync(); + await Client.PutAsJsonAsync(url, resource); + + // Log out. + Client.DefaultRequestHeaders.Authorization = null; + } + protected async Task ArrangeAuthenticatedAdminAsync() { var userInfo = new User() From b2bad0d73dcfefc07db4ed859af290077420ada4 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 18:05:00 +0200 Subject: [PATCH 10/14] Make PUT /contracts/{id} idempotent --- src/Server/Controllers/ContractsController.cs | 5 ++--- .../Contracts/ContractApiIntegrationTests.cs | 21 ++++++++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/Server/Controllers/ContractsController.cs b/src/Server/Controllers/ContractsController.cs index 1bb7c21f..3c4c8cb1 100644 --- a/src/Server/Controllers/ContractsController.cs +++ b/src/Server/Controllers/ContractsController.cs @@ -95,10 +95,9 @@ public IActionResult CreateContract([FromBody] Contract contract, Guid id) { _contracts.Add(contract); } - catch (IdentifierAlreadyTakenException e) + catch (IdentifierAlreadyTakenException) { - Logger.LogInformation("ID of contract was already taken: {Error}", e.Message); - return BadRequest(); + _contracts.UpdateContract(contract); } return Ok(); diff --git a/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs b/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs index 356c237c..9a687482 100644 --- a/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs +++ b/tests/Server.Tests.Integration/Contracts/ContractApiIntegrationTests.cs @@ -14,7 +14,7 @@ public ContractApiIntegrationTests(TestWebApplicationFactory factory) } [Fact] - public async Task PutContract_CreatesNewContract_IfItDoesNotExist() + public async Task PutContract_CreatesNewContract_WhenItDoesNotExist() { // Arrange await ArrangeAuthenticatedAdminAsync(); @@ -28,4 +28,23 @@ public async Task PutContract_CreatesNewContract_IfItDoesNotExist() var contracts = await Client.GetFromJsonAsync>("api/v1/contracts"); contracts.Should().ContainEquivalentOf(newContract); } + + [Fact] + public async Task PutContract_UpdatesExistingContract_WhenItExists() + { + // Arrange + var contract = new Contract(); + string endpointUrl = $"api/v1/contracts/{contract.Id}"; + await PutResourceAsync(endpointUrl, contract); + await ArrangeAuthenticatedAdminAsync(); + contract.Name = "Modified name"; + + // Act + HttpResponseMessage response = await Client.PutAsJsonAsync(endpointUrl, contract); + + // Assert + response.Should().BeSuccessful(); + var contracts = await Client.GetFromJsonAsync>("api/v1/contracts"); + contracts.Should().ContainEquivalentOf(contract); + } } From 30ea26523d234b10783792f7e87e51f56d0588b2 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 18:13:28 +0200 Subject: [PATCH 11/14] Replace table item when it is updated --- src/Client/Pages/Admin/AdminPage.razor | 2 +- src/Client/Pages/Admin/ContractTable.razor | 18 +++++++++++++++--- .../Pages/Admin/ContractListTests.cs | 4 ++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Client/Pages/Admin/AdminPage.razor b/src/Client/Pages/Admin/AdminPage.razor index 3a8e35fc..ba70de64 100644 --- a/src/Client/Pages/Admin/AdminPage.razor +++ b/src/Client/Pages/Admin/AdminPage.razor @@ -40,7 +40,7 @@ private void AddContractToTable(Contract contract) { - _contractTable.Add(contract); + _contractTable.AddOrUpdate(contract); } private void AddUserToTable(User user) diff --git a/src/Client/Pages/Admin/ContractTable.razor b/src/Client/Pages/Admin/ContractTable.razor index a5bcbe35..1ad9959d 100644 --- a/src/Client/Pages/Admin/ContractTable.razor +++ b/src/Client/Pages/Admin/ContractTable.razor @@ -20,7 +20,8 @@ @foreach (Contract contract in contracts) { - } @@ -41,9 +42,20 @@ /// Adds a contract to the list. ///
/// The contract to add. - public void Add(Contract contract) + public void AddOrUpdate(Contract contract) { - _dataFetcher.Data?.Add(contract); + if (_dataFetcher.Data is null) + return; + + if (_dataFetcher.Data.Any(other => other.Id == contract.Id)) + { + _dataFetcher.Data.Remove(contract); + _dataFetcher.Data.Add(contract); + } + else + { + _dataFetcher.Data.Add(contract); + } InvokeAsync(StateHasChanged); } diff --git a/tests/Client.Tests/Pages/Admin/ContractListTests.cs b/tests/Client.Tests/Pages/Admin/ContractListTests.cs index e81047bc..c0f198c0 100644 --- a/tests/Client.Tests/Pages/Admin/ContractListTests.cs +++ b/tests/Client.Tests/Pages/Admin/ContractListTests.cs @@ -30,7 +30,7 @@ public void AddingContract_RendersTheNewContract() var newContract = new Contract { Name = newContractName, }; // Act - cut.Instance.Add(newContract); + cut.Instance.AddOrUpdate(newContract); cut.WaitForState(() => cut.FindAll(itemSelector).Count == 3); // Assert @@ -55,7 +55,7 @@ public void AddingContract_DoesNotThrow_BeforeContractsAreFetched() var newContract = new Contract { Name = newContractName, }; // Act - Action add = () => cut.Instance.Add(newContract); + Action add = () => cut.Instance.AddOrUpdate(newContract); // Assert add.Should().NotThrow(); From 2f27a47c7b44d0e02f48790c8679ba236a40977c Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 18:15:29 +0200 Subject: [PATCH 12/14] Rename test classes --- .../Pages/Admin/{ContractListTests.cs => ContractTableTests.cs} | 2 +- .../Pages/Admin/{UserListTests.cs => UserTableTests.cs} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename tests/Client.Tests/Pages/Admin/{ContractListTests.cs => ContractTableTests.cs} (98%) rename tests/Client.Tests/Pages/Admin/{UserListTests.cs => UserTableTests.cs} (97%) diff --git a/tests/Client.Tests/Pages/Admin/ContractListTests.cs b/tests/Client.Tests/Pages/Admin/ContractTableTests.cs similarity index 98% rename from tests/Client.Tests/Pages/Admin/ContractListTests.cs rename to tests/Client.Tests/Pages/Admin/ContractTableTests.cs index c0f198c0..275176c2 100644 --- a/tests/Client.Tests/Pages/Admin/ContractListTests.cs +++ b/tests/Client.Tests/Pages/Admin/ContractTableTests.cs @@ -13,7 +13,7 @@ namespace Client.Tests.Pages.Admin; -public class ContractListTests : UITestFixture +public class ContractTableTests : UITestFixture { [Fact] public void AddingContract_RendersTheNewContract() diff --git a/tests/Client.Tests/Pages/Admin/UserListTests.cs b/tests/Client.Tests/Pages/Admin/UserTableTests.cs similarity index 97% rename from tests/Client.Tests/Pages/Admin/UserListTests.cs rename to tests/Client.Tests/Pages/Admin/UserTableTests.cs index 80ec5850..a990f953 100644 --- a/tests/Client.Tests/Pages/Admin/UserListTests.cs +++ b/tests/Client.Tests/Pages/Admin/UserTableTests.cs @@ -13,7 +13,7 @@ namespace Client.Tests.Pages.Admin; -public class UserListTests : UITestFixture +public class UserTableTests : UITestFixture { [Fact] public void AddingUser_RendersTheNewUser() From 8bac09b62188ba68358252a1f6ca7934ec0955ae Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 May 2022 18:22:27 +0200 Subject: [PATCH 13/14] Add test for updating of contract table --- src/Client/Pages/Admin/ContractTable.razor | 2 +- .../Pages/Admin/ContractTableTests.cs | 24 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Client/Pages/Admin/ContractTable.razor b/src/Client/Pages/Admin/ContractTable.razor index 1ad9959d..b349b5a7 100644 --- a/src/Client/Pages/Admin/ContractTable.razor +++ b/src/Client/Pages/Admin/ContractTable.razor @@ -49,7 +49,7 @@ if (_dataFetcher.Data.Any(other => other.Id == contract.Id)) { - _dataFetcher.Data.Remove(contract); + _dataFetcher.Data.RemoveAll(toRemove => toRemove.Id == contract.Id); _dataFetcher.Data.Add(contract); } else diff --git a/tests/Client.Tests/Pages/Admin/ContractTableTests.cs b/tests/Client.Tests/Pages/Admin/ContractTableTests.cs index 275176c2..8656476b 100644 --- a/tests/Client.Tests/Pages/Admin/ContractTableTests.cs +++ b/tests/Client.Tests/Pages/Admin/ContractTableTests.cs @@ -68,7 +68,8 @@ public async Task RemovingContract_RendersWithoutTheContractAsync() var firstContract = new Contract() { Name = "first", }; Contract[] contracts = { firstContract, new Contract() { Name = "Second", }, }; MockHttp.When("/api/v1/contracts").RespondJson(contracts); - MockHttp.When(HttpMethod.Delete, $"/api/v1/contracts/{firstContract.Id}").Respond(req => new HttpResponseMessage(HttpStatusCode.OK)); + MockHttp.When(HttpMethod.Delete, $"/api/v1/contracts/{firstContract.Id}") + .Respond(req => new HttpResponseMessage(HttpStatusCode.OK)); IRenderedComponent cut = Context.RenderComponent(); const string removeButton = "#confirm-remove"; @@ -83,4 +84,25 @@ public async Task RemovingContract_RendersWithoutTheContractAsync() elementWithNewName = contract => contract.TextContent.Contains(firstContract.Name); cut.FindAll(".contract-table-row").Should().NotContain(elementWithNewName); } + + [Fact] + public void UpdatingContract_RendersNewContent_WhenContractExists() + { + // Arrange + var contract = new Contract() { Name = "old name", }; + Contract[] contracts = { contract, }; + MockHttp.When("/api/v1/contracts").RespondJson(contracts); + + IRenderedComponent cut = Context.RenderComponent(); + cut.WaitForAssertion(() => cut.Markup.Should().Contain(contract.Name)); + + const string newName = "new name"; + + // Act + contract.Name = newName; + cut.Instance.AddOrUpdate(contract); + + // Assert + cut.WaitForAssertion(() => cut.Markup.Should().Contain(newName)); + } } From 313632d7391f3ec62ede4d408012d41147c60796 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 11 May 2022 10:55:06 +0200 Subject: [PATCH 14/14] Make scroll padding apply to all headers --- src/Client/Pages/Admin/ContractForm.razor | 2 +- src/Client/wwwroot/css/app.css | 22 +++++++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Client/Pages/Admin/ContractForm.razor b/src/Client/Pages/Admin/ContractForm.razor index be9f2894..7d4d9adc 100644 --- a/src/Client/Pages/Admin/ContractForm.razor +++ b/src/Client/Pages/Admin/ContractForm.razor @@ -8,7 +8,7 @@ @inject HttpClient _http @inject IJSRuntime _js -

Skapa nytt kontrakt

+

Skapa nytt kontrakt

diff --git a/src/Client/wwwroot/css/app.css b/src/Client/wwwroot/css/app.css index 7149f012..e6510f68 100644 --- a/src/Client/wwwroot/css/app.css +++ b/src/Client/wwwroot/css/app.css @@ -23,6 +23,10 @@ p { line-height: 1.8125rem; } +h1, h2, h3, h4, h5, h6 { + scroll-margin-top: 4.5rem; +} + h1, h2 { font-family: 'Merriweather', Helvetica, Arial, sans-serif; letter-spacing: 0; @@ -103,12 +107,12 @@ a, .btn-link { z-index: 1000; } - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; +} .blazor-error-boundary { background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; @@ -116,9 +120,9 @@ a, .btn-link { color: white; } - .blazor-error-boundary::after { - content: "An error has occurred." - } +.blazor-error-boundary::after { + content: "An error has occurred." +} table {