Skip to content

Commit c74e281

Browse files
authored
Use FluentValidation for Add alert journey (#2624)
We're moving to using FluentValidation across the board so we can have a consistent approach for SupportUi validation and the API. This adds FV to the first journey in the Support UI - add alert.
1 parent 163bbec commit c74e281

File tree

9 files changed

+141
-81
lines changed

9 files changed

+141
-81
lines changed

TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/Alert.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using EntityFrameworkCore.Projectables;
2+
using FluentValidation;
23
using TeachingRecordSystem.Core.Events.Legacy;
34

45
namespace TeachingRecordSystem.Core.DataStore.Postgres.Models;
@@ -10,6 +11,8 @@ public class Alert
1011
public const string PersonIdIndexName = "ix_alerts_person_id";
1112
public const string PersonForeignKeyName = "fk_alerts_person";
1213

14+
public const int DetailsMaxLength = 4000;
15+
1316
public required Guid AlertId { get; init; }
1417
public AlertType? AlertType { get; }
1518
public required Guid AlertTypeId { get; init; }
@@ -137,3 +140,41 @@ public void Update(
137140
};
138141
}
139142
}
143+
144+
public static class AlertValidationExtensions
145+
{
146+
public static IRuleBuilderOptions<T, string?> AlertDetails<T>(
147+
this IRuleBuilder<T, string?> ruleBuilder,
148+
Func<int, string> maxLengthMessage)
149+
{
150+
return ruleBuilder
151+
.MaximumLength(Alert.DetailsMaxLength).WithMessage(maxLengthMessage(Alert.DetailsMaxLength));
152+
}
153+
154+
public static IRuleBuilderOptions<T, string?> AlertLink<T>(
155+
this IRuleBuilder<T, string?> ruleBuilder,
156+
string invalidUrlMessage)
157+
{
158+
return ruleBuilder
159+
.Must(link => TrsUriHelper.TryCreateWebsiteUri(link, out _)).WithMessage(invalidUrlMessage);
160+
}
161+
162+
public static IRuleBuilderOptions<T, DateOnly?> AlertStartDate<T>(
163+
this IRuleBuilder<T, DateOnly?> ruleBuilder,
164+
DateOnly today,
165+
string requiredMessage,
166+
string dateInFutureMessage)
167+
{
168+
return ruleBuilder
169+
.NotNull().WithMessage(requiredMessage)
170+
.LessThanOrEqualTo(today).WithMessage(dateInFutureMessage);
171+
}
172+
173+
public static IRuleBuilderOptions<T, Guid?> AlertType<T>(
174+
this IRuleBuilder<T, Guid?> ruleBuilder,
175+
string requiredMessage)
176+
{
177+
return ruleBuilder
178+
.NotNull().WithMessage(requiredMessage);
179+
}
180+
}

TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/Alerts/AddAlert/Details.cshtml.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
using System.ComponentModel.DataAnnotations;
21
using Microsoft.AspNetCore.Mvc;
32
using Microsoft.AspNetCore.Mvc.Filters;
43
using Microsoft.AspNetCore.Mvc.RazorPages;
4+
using TeachingRecordSystem.Core.DataStore.Postgres.Models;
55
using TeachingRecordSystem.SupportUi.Pages.Shared.Evidence;
66

77
namespace TeachingRecordSystem.SupportUi.Pages.Alerts.AddAlert;
88

99
[Journey(JourneyNames.AddAlert), RequireJourneyInstance]
1010
public class DetailsModel(SupportUiLinkGenerator linkGenerator, EvidenceUploadManager evidenceUploadManager) : PageModel
1111
{
12+
private readonly InlineValidator<DetailsModel> _validator = new()
13+
{
14+
v => v.RuleFor(m => m.Details).AlertDetails(
15+
maxLengthMessage: maxLength => $"Details must be {maxLength} characters or less")
16+
};
17+
1218
public JourneyInstance<AddAlertState>? JourneyInstance { get; set; }
1319

1420
[FromQuery]
@@ -22,7 +28,6 @@ public class DetailsModel(SupportUiLinkGenerator linkGenerator, EvidenceUploadMa
2228
public string? AlertTypeName { get; set; }
2329

2430
[BindProperty]
25-
[MaxLength(UiDefaults.DetailMaxCharacterCount, ErrorMessage = $"Details {UiDefaults.DetailMaxCharacterCountErrorMessage}")]
2631
public string? Details { get; set; }
2732

2833
public void OnGet()
@@ -32,10 +37,7 @@ public void OnGet()
3237

3338
public async Task<IActionResult> OnPostAsync()
3439
{
35-
if (!ModelState.IsValid)
36-
{
37-
return this.PageWithErrors();
38-
}
40+
await _validator.ValidateAndThrowAsync(this);
3941

4042
await JourneyInstance!.UpdateStateAsync(state =>
4143
{

TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/Alerts/AddAlert/Link.cshtml.cs

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
using System.ComponentModel.DataAnnotations;
21
using Microsoft.AspNetCore.Mvc;
32
using Microsoft.AspNetCore.Mvc.Filters;
43
using Microsoft.AspNetCore.Mvc.RazorPages;
4+
using TeachingRecordSystem.Core.DataStore.Postgres.Models;
55
using TeachingRecordSystem.SupportUi.Pages.Shared.Evidence;
66

77
namespace TeachingRecordSystem.SupportUi.Pages.Alerts.AddAlert;
88

99
[Journey(JourneyNames.AddAlert), RequireJourneyInstance]
1010
public class LinkModel(SupportUiLinkGenerator linkGenerator, EvidenceUploadManager evidenceUploadManager) : PageModel
1111
{
12+
private readonly InlineValidator<LinkModel> _validator = new()
13+
{
14+
v => v.RuleFor(m => m.AddLink).NotNull().WithMessage("Select yes if you want to add a link to a panel outcome"),
15+
v => v.RuleFor(m => m.Link).AlertLink("Enter a valid URL").When(m => m.AddLink == true)
16+
};
17+
1218
public JourneyInstance<AddAlertState>? JourneyInstance { get; set; }
1319

1420
[FromQuery]
@@ -20,7 +26,6 @@ public class LinkModel(SupportUiLinkGenerator linkGenerator, EvidenceUploadManag
2026
public string? PersonName { get; set; }
2127

2228
[BindProperty]
23-
[Required(ErrorMessage = "Select yes if you want to add a link to a panel outcome")]
2429
public bool? AddLink { get; set; }
2530

2631
[BindProperty]
@@ -34,15 +39,7 @@ public void OnGet()
3439

3540
public async Task<IActionResult> OnPostAsync()
3641
{
37-
if (AddLink == true && !TrsUriHelper.TryCreateWebsiteUri(Link, out _))
38-
{
39-
ModelState.AddModelError(nameof(Link), "Enter a valid URL");
40-
}
41-
42-
if (!ModelState.IsValid)
43-
{
44-
return this.PageWithErrors();
45-
}
42+
await _validator.ValidateAndThrowAsync(this);
4643

4744
await JourneyInstance!.UpdateStateAsync(state =>
4845
{

TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/Alerts/AddAlert/Reason.cshtml.cs

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.ComponentModel.DataAnnotations;
21
using Microsoft.AspNetCore.Mvc;
32
using Microsoft.AspNetCore.Mvc.Filters;
43
using Microsoft.AspNetCore.Mvc.RazorPages;
@@ -9,6 +8,21 @@ namespace TeachingRecordSystem.SupportUi.Pages.Alerts.AddAlert;
98
[Journey(JourneyNames.AddAlert), RequireJourneyInstance]
109
public class ReasonModel(SupportUiLinkGenerator linkGenerator, EvidenceUploadManager evidenceUploadManager) : PageModel
1110
{
11+
private readonly InlineValidator<ReasonModel> _validator = new()
12+
{
13+
v => v.RuleFor(m => m.AddReason)
14+
.NotNull().WithMessage("Select a reason"),
15+
v => v.RuleFor(m => m.HasAdditionalReasonDetail)
16+
.NotNull().WithMessage("Select yes if you want to add more information"),
17+
v => v.RuleFor(m => m.AddReasonDetail)
18+
.NotNull().WithMessage("Enter additional detail")
19+
.MaximumLength(4000).WithMessage("Additional detail must be 4000 characters or less")
20+
.When(m => m.HasAdditionalReasonDetail == true),
21+
v => v.RuleFor(m => m.Evidence.UploadEvidence)
22+
.NotNull().WithMessage("Select yes if you want to upload evidence"),
23+
v => v.RuleFor(m => m.Evidence).Evidence()
24+
};
25+
1226
public JourneyInstance<AddAlertState>? JourneyInstance { get; set; }
1327

1428
[FromQuery]
@@ -20,15 +34,12 @@ public class ReasonModel(SupportUiLinkGenerator linkGenerator, EvidenceUploadMan
2034
public string? PersonName { get; set; }
2135

2236
[BindProperty]
23-
[Required(ErrorMessage = "Select a reason")]
2437
public AddAlertReasonOption? AddReason { get; set; }
2538

2639
[BindProperty]
27-
[Required(ErrorMessage = "Select yes if you want to add more information")]
2840
public bool? HasAdditionalReasonDetail { get; set; }
2941

3042
[BindProperty]
31-
[MaxLength(UiDefaults.DetailMaxCharacterCount, ErrorMessage = $"Additional detail {UiDefaults.DetailMaxCharacterCountErrorMessage}")]
3243
public string? AddReasonDetail { get; set; }
3344

3445
[BindProperty]
@@ -57,17 +68,9 @@ public void OnGet()
5768

5869
public async Task<IActionResult> OnPostAsync()
5970
{
60-
if (HasAdditionalReasonDetail == true && AddReasonDetail is null)
61-
{
62-
ModelState.AddModelError(nameof(AddReasonDetail), "Enter additional detail");
63-
}
71+
await _validator.ValidateAndThrowAsync(this);
6472

65-
await evidenceUploadManager.ValidateAndUploadAsync<ReasonModel>(m => m.Evidence, ViewData);
66-
67-
if (!ModelState.IsValid)
68-
{
69-
return this.PageWithErrors();
70-
}
73+
await evidenceUploadManager.UploadAsync(Evidence);
7174

7275
await JourneyInstance!.UpdateStateAsync(state =>
7376
{

TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/Alerts/AddAlert/StartDate.cshtml.cs

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
using System.ComponentModel.DataAnnotations;
21
using Microsoft.AspNetCore.Mvc;
32
using Microsoft.AspNetCore.Mvc.Filters;
43
using Microsoft.AspNetCore.Mvc.RazorPages;
4+
using TeachingRecordSystem.Core.DataStore.Postgres.Models;
55
using TeachingRecordSystem.SupportUi.Pages.Shared.Evidence;
66

77
namespace TeachingRecordSystem.SupportUi.Pages.Alerts.AddAlert;
88

99
[Journey(JourneyNames.AddAlert), RequireJourneyInstance]
1010
public class StartDateModel(SupportUiLinkGenerator linkGenerator, EvidenceUploadManager evidenceUploadManager, IClock clock) : PageModel
1111
{
12+
private readonly InlineValidator<StartDateModel> _validator = new()
13+
{
14+
v => v.RuleFor(m => m.StartDate).AlertStartDate(
15+
clock.Today,
16+
requiredMessage: "Enter a start date",
17+
dateInFutureMessage: "Start date cannot be in the future")
18+
};
19+
1220
public JourneyInstance<AddAlertState>? JourneyInstance { get; set; }
1321

1422
[FromQuery]
@@ -20,7 +28,6 @@ public class StartDateModel(SupportUiLinkGenerator linkGenerator, EvidenceUpload
2028
public string? PersonName { get; set; }
2129

2230
[BindProperty]
23-
[Required(ErrorMessage = "Enter a start date")]
2431
public DateOnly? StartDate { get; set; }
2532

2633
public void OnGet()
@@ -30,15 +37,7 @@ public void OnGet()
3037

3138
public async Task<IActionResult> OnPostAsync()
3239
{
33-
if (StartDate > clock.Today)
34-
{
35-
ModelState.AddModelError(nameof(StartDate), "Start date cannot be in the future");
36-
}
37-
38-
if (!ModelState.IsValid)
39-
{
40-
return this.PageWithErrors();
41-
}
40+
await _validator.ValidateAndThrowAsync(this);
4241

4342
await JourneyInstance!.UpdateStateAsync(state =>
4443
{

TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/Alerts/AddAlert/Type.cshtml.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
using System.ComponentModel.DataAnnotations;
21
using Microsoft.AspNetCore.Authorization;
32
using Microsoft.AspNetCore.Mvc;
43
using Microsoft.AspNetCore.Mvc.Filters;
54
using Microsoft.AspNetCore.Mvc.RazorPages;
5+
using TeachingRecordSystem.Core.DataStore.Postgres.Models;
66
using TeachingRecordSystem.SupportUi.Infrastructure.Security;
77
using TeachingRecordSystem.SupportUi.Infrastructure.Security.Requirements;
88
using TeachingRecordSystem.SupportUi.Pages.Shared.Evidence;
@@ -16,6 +16,11 @@ public class TypeModel(
1616
EvidenceUploadManager evidenceUploadManager,
1717
IAuthorizationService authorizationService) : PageModel
1818
{
19+
private readonly InlineValidator<TypeModel> _validator = new()
20+
{
21+
v => v.RuleFor(m => m.AlertTypeId).AlertType(requiredMessage: "Select an alert type")
22+
};
23+
1924
public JourneyInstance<AddAlertState>? JourneyInstance { get; set; }
2025

2126
[FromQuery]
@@ -27,7 +32,6 @@ public class TypeModel(
2732
public string? PersonName { get; set; }
2833

2934
[BindProperty]
30-
[Required(ErrorMessage = "Select an alert type")]
3135
public Guid? AlertTypeId { get; set; }
3236

3337
public AlertCategoryInfo[]? Categories { get; set; }
@@ -41,10 +45,7 @@ public void OnGet()
4145

4246
public async Task<IActionResult> OnPostAsync()
4347
{
44-
if (!ModelState.IsValid)
45-
{
46-
return this.PageWithErrors();
47-
}
48+
await _validator.ValidateAndThrowAsync(this);
4849

4950
var selectedType = AlertTypes!.Single(t => t.AlertTypeId == AlertTypeId);
5051

TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/Shared/Evidence/EvidenceUploadManager.cs

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,41 @@ namespace TeachingRecordSystem.SupportUi.Pages.Shared.Evidence;
66

77
public class EvidenceUploadManager(IFileService fileService, IModelExpressionProvider modelExpressionProvider)
88
{
9-
public async Task ValidateAndUploadAsync<TModel>(Expression<Func<TModel, EvidenceUploadModel>> evidenceExpression, ViewDataDictionary viewData)
9+
public async Task UploadAsync(EvidenceUploadModel evidence)
1010
{
11-
var expressionBuilder = new ModelExpressionBuilder<TModel, EvidenceUploadModel>(modelExpressionProvider, evidenceExpression, viewData);
12-
var evidence = expressionBuilder.Model;
13-
14-
if (evidence.UploadEvidence == true && evidence.EvidenceFile is null && evidence.UploadedEvidenceFile is null)
15-
{
16-
var expression = expressionBuilder.GetModelExpressionFor(e => e.EvidenceFile);
17-
viewData.ModelState.AddModelError(expression.Name, "Select a file");
18-
}
19-
2011
// Delete any previously uploaded file if they're uploading a new one,
2112
// or choosing not to upload evidence (check for UploadEvidence != true because if
2213
// UploadEvidence somehow got set to null we still want to delete the file)
2314
if (evidence.UploadedEvidenceFile is UploadedEvidenceFile file && (evidence.EvidenceFile is not null || evidence.UploadEvidence != true))
2415
{
25-
await fileService!.DeleteFileAsync(file.FileId);
16+
await fileService.DeleteFileAsync(file.FileId);
2617
evidence.UploadedEvidenceFile = null;
2718
}
2819

2920
// Upload the file and set the display fields even if the rest of the form is invalid
3021
// otherwise the user will have to re-upload every time they re-submit
31-
if (evidence.UploadEvidence == true && evidence.EvidenceFile is IFormFile uploadedFile)
22+
if (evidence is { UploadEvidence: true, EvidenceFile: IFormFile uploadedFile })
3223
{
33-
using var stream = evidence.EvidenceFile.OpenReadStream();
34-
var fileId = await fileService!.UploadFileAsync(stream, evidence.EvidenceFile.ContentType);
24+
await using var stream = evidence.EvidenceFile.OpenReadStream();
25+
var fileId = await fileService.UploadFileAsync(stream, evidence.EvidenceFile.ContentType);
3526
evidence.UploadedEvidenceFile = new(fileId, uploadedFile.FileName, uploadedFile.Length);
36-
evidence.EvidenceFile = null;
3727
}
3828
}
3929

30+
public async Task ValidateAndUploadAsync<TModel>(Expression<Func<TModel, EvidenceUploadModel>> evidenceExpression, ViewDataDictionary viewData)
31+
{
32+
var expressionBuilder = new ModelExpressionBuilder<TModel, EvidenceUploadModel>(modelExpressionProvider, evidenceExpression, viewData);
33+
var evidence = expressionBuilder.Model;
34+
35+
if (evidence.UploadEvidence == true && evidence.EvidenceFile is null && evidence.UploadedEvidenceFile is null)
36+
{
37+
var expression = expressionBuilder.GetModelExpressionFor(e => e.EvidenceFile);
38+
viewData.ModelState.AddModelError(expression.Name, "Select a file");
39+
}
40+
41+
await UploadAsync(evidence);
42+
}
43+
4044
public async Task DeleteUploadedFileAsync(UploadedEvidenceFile? evidenceFile)
4145
{
4246
if (evidenceFile is UploadedEvidenceFile file)
@@ -51,8 +55,8 @@ public class ModelExpressionBuilder<TContainer, TModel>(
5155
Expression<Func<TContainer, TModel>> modelExpressionFromContainer,
5256
ViewDataDictionary viewData)
5357
{
54-
private ViewDataDictionary<TContainer> _typedViewData = new(viewData);
55-
private Func<TContainer, TModel> _getModel = modelExpressionFromContainer.Compile();
58+
private readonly ViewDataDictionary<TContainer> _typedViewData = new(viewData);
59+
private readonly Func<TContainer, TModel> _getModel = modelExpressionFromContainer.Compile();
5660

5761
public TModel Model => _getModel(_typedViewData.Model);
5862

0 commit comments

Comments
 (0)