.NET/ASP.NET

ASP.NET Core - [Blazor] 5. Blazor Form과 Data

클리엘 2023. 3. 22. 17:27
728x90

이번 글에서는 Blazor가 Data validation을 포함하여 HTML form을 처리하기 위해 제공하는 몇 가지 기능에 대해 알아볼 것입니다. Blazor가 제공하는 내장 component에 대한 것들과 함께 이들을 실제 어떤 방식으로 사용할 수 있는지와 Blazor model이 Entity Framework Core를 통해 어떤 형태로 예상하지 못한 결과를 유발하고 또 이러한 issue를 어떻게 해결할 수 있는지 등을 포함할 것입니다. 마지막으로 CRUD(Create, Reading, Updating, Deleting)가 가능한 간단한 form application을 만들어 보고 사용자의 경험을 향상하기 위해 어떻게 Blazor form 기능을 확장할 수 있는지도 알아보겠습니다.

 

1. Project 준비하기

 

해당 글에서 필요한 예제 project는 이전글에서 사용하던 project를 그대로 사용할 것입니다. 다만 부분적으로 필요한 것이 있는데 우선 project에 Blazor/Form folder를 생성하고 EmptyLayout.razor이름의 Razor component를 아래와 같이 추가합니다. 해당 Razor component는 이 글에서 main layout으로 사용할 것입니다.

@inherits LayoutComponentBase

<div class="m-2">
	@Body
</div>

이어서 FormSpy.razor이름의 Razor component file도 아래와 같이 같은 folder에 추가합니다. 해당 component는 수정될 값과 함께 form요소를 표시하는데 사용될 것입니다.

<div class="container-fluid no-gutters">
	<div class="row">
		<div class="col">
			@ChildContent
		</div>
		<div class="col">
			<table class="table table-sm table-striped table-bordered">
				<thead>
					<tr><th colspan="2" class="text-center">Data Summary</th></tr>
				</thead>
				<tbody>
					<tr><th>ID</th><td>@ProductData?.ProductId</td></tr>
					<tr><th>Name</th><td>@ProductData?.ProductName</td></tr>
					<tr><th>Prirce</th><td>@ProductData?.ProductPrice</td></tr>
					<tr><th>Category</th><td>@ProductData?.ProductCategory.CategoryName</td></tr>
					<tr><th>Manufactorer</th><td>@ProductData?.ProductManufacturer.ManufacturerName</td></tr>
				</tbody>
			</table>
		</div>
	</div>
</div>

@code {
	[Parameter]
	public RenderFragment? ChildContent { get; set; }

	[Parameter]
	public Product ProductData { get; set; } = new();
}

다음으로 Editor.razor 이름의 component도 같은 folder에 아래와 같이 추가합니다. 해당 component는 기존의 product 개체를 수정하거나 새로운 product 개체를 추가하기 위해 사용됩니다.

@page "/forms/edit/{id:int}"

@layout EmptyLayout

<h4 class="bg-primary text-center text-white p-2">Edit</h4>
<FormSpy ProductData="ProductData">
	<h4 class="text-center">Form Placeholder</h4>
	<div class="text-center">
		<NavLink class="btn btn-secondary mt-2" href="/forms">Back</NavLink>
	</div>
</FormSpy>

@code {
	[Inject]
	public NavigationManager? NavManager { get; set; }

	[Inject]
	BlazorTDBContext? Context { get; set; }

	[Parameter]
	public int Id { get; set; }

	public Product ProductData { get; set; } = new();

	protected async override Task OnParametersSetAsync()
	{
		if (Context != null)
		{
			ProductData = await Context.Product.FindAsync(Id) ?? new Product();
		}
	}
}

위 예제의 component는 기본 layout을 재정의하기 위해 @layout 표현식을 사용하고 EmptyLayout을 선택합니다. 예제의 layout은 placeholder와 함게 ProductTable table을 표시하는 데 사용되며 여기에 form을 추가할 것입니다.

 

마지막으로 같은 folder에 List.razor이름의 component를 아래와 같이 추가합니다. 해당 component는 product개체의 list인 table을 통해 product를 표현할 것입니다.

@page "/forms"
@page "/forms/list"
@layout EmptyLayout

<h5 class="bg-primary text-white text-center p-2">Product</h5>
<table class="table table-sm table-striped table-bordered">
	<thead>
		<tr>
			<th>ID</th>
			<th>Name</th>
			<th>Category</th>
			<th>Manufactorer</th>
			<th></th>
		</tr>
	</thead>
	<tbody>
		@if (Products.Count() == 0)
		{
			<tr><th colspan="5" class="p-4 text-center">Loading Data...</th></tr>
		}
		else
		{
			@foreach (Product p in Products)
			{
				<tr>
					<td>@p.ProductId</td>
					<td>@p.ProductName</td>
					<td>@p.ProductCategory.CategoryName</td>
					<td>@p.ProductManufacturer.ManufacturerName</td>
					<td>
						<NavLink class="btn btn-sm btn-warning" href="@GetEditUrl(p.ProductId)">
							Edit
						</NavLink>
					</td>
				</tr>
			}
		}
	</tbody>
</table>

@code {
	[Inject]
	public BlazorTDBContext? Context { get; set; }

	public IEnumerable<Product> Products { get; set; } = Enumerable.Empty<Product>();

	protected override void OnInitialized()
	{
		Products = Context?.Product?.Include(p => p.ProductCategory).Include(p => p.ProductManufacturer) ?? Enumerable.Empty<Product>();
	}

	string GetEditUrl(long id) => $"/forms/edit/{id}";
}

project를 실행하고 /forms로 URL을 요청합니다. 그러면 다음과 같은 table을 응답으로 생성할 것이며

해당 list중 하나의 Edit button을 click 하며 Form Placeholder와 선택한 product개체에 대한 속성값이 표시되는 Data Summary부분에 대한 응답이 다음과 같이 생성되는지를 확인합니다.

2. Blazor Form Component

 

Blazor는 요소를 render하는데 사용되는 일련의 내장된 component를 제공함으로써 사용자와의 상호작용과 validation통합 후에 server-side component의 속성이 update 됨을 확인합니다. 아래 표는 Blazor에서 제공하는 component들을 나열한 것입니다.

EditForm 해당 component는 data validation과 연결된 form요소를 render합니다.
InputText 해당 component는 C# 문자열 속성에 bound되는 input요소를 render합니다.
InputCheckbox 해당 component는 type attribute가 checkbox이며 C# bool 속성으로 bound되는 input요소를 render합니다.
InputDate 해당 component는 type attribute가 date이며 C# DateTime또는 DateTimeOffset 속성으로 bound되는 input요소를 render합니다.
InputNumber 해당 component는 type attribute가 number이면서 C# int, long, float, double 또는 decimal 값으로 bound되는 input요소를 render합니다.
InputTextArea 해당 component는 C# 문자열 속성으로 bound되는 textarea component를 render합니다.

다른 component가 작동하려면 EditForm component를 사용해야 합니다. 아래 예제는 Blazor/Forms folder의 Editor.razor file을 변경한 것으로 Product class에서 정의된 2개의 속성을 표현하는 InputText component와 함께 EditForm을 추가한 것입니다.

<FormSpy ProductData="ProductData">
	<EditForm Model="ProductData">
		<div class="form-group">
			<label>Product ID</label>
			<InputNumber class="form-control" @bind-Value="ProductData.ProductId" disabled />
		</div>
		<div class="form-group">
			<label>ProductName</label>
			<InputText class="form-control" @bind-Value="ProductData.ProductName" />
		</div>
		<div class="form-group">
			<label>Price</label>
			<InputNumber class="form-control" @bind-Value="ProductData.ProductPrice" />
		</div>
		<div class="text-center">
			<NavLink class="btn btn-secondary" href="/forms">Back</NavLink>
		</div>
	</EditForm>
</FormSpy>

EditForm component는 form요소를 render하며 validation기능의 기반을 제공하고 Model attribute로 form이 편집과 validate를 위해 사용하는 개체를 EditForm에 제공합니다.

 

EditForm에서는 Product class의 ID와 Name, Price만 제공하고 있으므로 상위 FormSpy에도 동일하게 맞춰주도록 합니다.

<FormSpy ProductData="ProductData">
	<EditForm Model="ProductData">
		<div class="form-group">
			<label>Product ID</label>
			<InputNumber class="form-control" @bind-Value="ProductData.ProductId" disabled />
		</div>
		<div class="form-group">
			<label>ProductName</label>
			<InputText class="form-control" @bind-Value="ProductData.ProductName" />
		</div>
		<div class="form-group">
			<label>Price</label>
			<InputNumber class="form-control" @bind-Value="ProductData.ProductPrice" />
		</div>
		<div class="text-center">
			<NavLink class="btn btn-secondary" href="/forms">Back</NavLink>
		</div>
	</EditForm>
</FormSpy>

상기 table에서 이름이 input으로 시작하는 component는 input또는 textarea요소를 단일 model 속성을 표시하기 위해 사용됩니다. 예제의 component는 @bind-value attribute를 사용하여 model 속성에 할당된 Value라는 이름의 사용자 정의 binding을 정의하고 있습니다. 속성 수준 component는 사용자에게 제공되는 속성의 type과 일치해야 합니다. 이는 InputNumber component가 ProductId와 ProductPrice에 사용된 반면 InputText component를 Product class의 ProductName에 사용한 이유입니다. 만약 속성 수준 component가 잘못된 type의 model 속성에 사용되었다면 component가 HTML요소에 입력된 값을 읽으려고 시도할 때 오류를 발생시키게 될 것입니다.

 

project를 실행하고 /forms/edit/2로 URL을 요청합니다. 그러면 다음과 같이 표시된 2개의 input요소를 볼 수 있게 되고 이 상태에서 값을 변경한 뒤 Tab key를 통해 focus를 이동합니다. 이때 오른쪽 summary가 update되는 것을 확인할 수 있습니다.

내장 form component는 분할 attribute를 지원하기에 disabled attribute를 input요소에 적용된 ProductId속성에 해당하는 InputNumber요소에 적용하였습니다.

 

(1) 사용자 정의 Form Component 생성

 

Blazor는 단지 input과 textarea요소만을 위한 내장 component를 지원하고 있지만 다행스럽게도 간단한 방법으로 Blazor의 form기능으로 통합되는 사용자 정의 component를 생성할 수 있습니다. Blazor/forms folder에 CustomSelect.razor이름의 Raozr component를 아래와 같이 추가합니다.

@typeparam TValue
@inherits InputBase<TValue>

@using System.Diagnostics.CodeAnalysis

<select class="form-control @CssClass" value="@CurrentValueAsString" @onchange="@(ev => CurrentValueAsString = ev.Value as string)">
	@ChildContent

	@foreach (KeyValuePair<string, TValue> kvp in Values)
	{
		<option value="@kvp.Value">@kvp.Key</option>
	}
</select>

@code {
	[Parameter]
	public RenderFragment? ChildContent { get; set; }

	[Parameter]
	public IDictionary<string, TValue> Values { get; set; } = new Dictionary<string, TValue>();

	[Parameter]
	public Func<string, TValue>? Parser { get; set; }

	protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue? result, [NotNullWhen(false)] out string? validationErrorMessage)
	{
		try
		{
			if (Parser != null && value != null)
			{
				result = Parser(value);
				validationErrorMessage = null;
				return true;
			}

			result = default(TValue);
			validationErrorMessage = "Value or parser not defined";

			return false;
		}
		catch
		{
			result = default(TValue);
			validationErrorMessage = "The value is not valid";
			return false;
		}
	}
}

form component의 기반 class는 InputBase<TValue>이며 여기서 generic type 인수는 component가 표현하는 model 속성 type입니다.

 

기반 class는 대부분의 작업을 처리하며 사용자가 새로운 값을 아래와 같이 선택할때 event handler의 현재 값을 제공하기 위해 사용되는 CurrentValueAsString속성을 제공합니다.

<select class="form-control @CssClass" value="@CurrentValueAsString" @onchange="@(ev => CurrentValueAsString = ev.Value as string)">

또한 잠시 후 알아볼 data 유효성검사를 준비하기 위해 해당 component는 select요소의 class attribute에 있는 CssClass 속성의 값을 포함하고 있습니다.

 

예제에서는 TryParseValueFromString추상 method를 구현하였으므로 기반 class는 HTML요소에서 사용된 문자열값과 C# model 속성에 대한 해당값을 일치시킬 수 있습니다. 모든 C# data type에 대응하는 사용자 정의 select요소를 구현할 수는 없으므로 @typeparam 표현식을 사용해 generic type 매개변수를 정의하고 있습니다.

 

Values속성은 사용자에게 표시될 disctionary와 일치하는 문자열값과 C#값으로서 사용될 TValue값을 수신하는 데 사용됩니다. method는 분석된 값을 설정하는 데 사용되는 2개의 out매개변수와 문제가 발생한 경우 사용자에게 표시될 parser validation error message를 수신합니다. 예제에서는 generic type을 사용했으므로 Parser속성은 TValue값으로 문자열 값을 분석하기 위해 호출되는 함수를 수신합니다.

 

아래 예제는 Blazor/Forms folder의 Editor.razor file을 변경한 것으로 위 예제의 새로운 form component를 적용함으로서 사용자는  Product Class에 정의된 CategoryId와 ManufacturerId속성에 해당하는 값을 선택할 수 있습니다.

@page "/forms/edit/{id:int}"

@layout EmptyLayout

<h4 class="bg-primary text-center text-white p-2">Edit</h4>
<FormSpy ProductData="ProductData">
	<EditForm Model="ProductData">
		<div class="form-group">
			<label>Product ID</label>
			<InputNumber class="form-control" @bind-Value="ProductData.ProductId" disabled />
		</div>
		<div class="form-group">
			<label>ProductName</label>
			<InputText class="form-control" @bind-Value="ProductData.ProductName" />
		</div>
		<div class="form-group">
			<label>Price</label>
			<InputNumber class="form-control" @bind-Value="ProductData.ProductPrice" />
		</div>
		<div class="form-group">
			<label>Category ID</label>
			<CustomSelect TValue="int?" Values="Categories" Parser="@((string str) => int.Parse(str))" @bind-Value="ProductData.ProductCategoryId">
				<option selected disabled value="0">Choose a Category</option>
			</CustomSelect>
		</div>
		<div class="form-group">
			<label>Manufacturer ID</label>
			<CustomSelect TValue="int?" Values="Manufacturers" Parser="@((string str) => int.Parse(str))" @bind-Value="ProductData.ProductManufacturerId">
				<option selected disabled value="0">Choose a Manufacturer</option>
			</CustomSelect>
		</div>
		<div class="text-center">
			<NavLink class="btn btn-secondary" href="/forms">Back</NavLink>
		</div>
	</EditForm>
</FormSpy>

@code {
	[Inject]
	public NavigationManager? NavManager { get; set; }

	[Inject]
	BlazorTDBContext? Context { get; set; }

	[Parameter]
	public int Id { get; set; }

	public Product ProductData { get; set; } = new();

	public IDictionary<string, int?> Categories { get; set; } = new Dictionary<string, int?>();
	public IDictionary<string, int?> Manufacturers { get; set; } = new Dictionary<string, int?>();

	protected async override Task OnParametersSetAsync()
	{
		if (Context != null)
		{
			ProductData = await Context.Product.Where(x => x.ProductId == Id).FirstOrDefaultAsync() ?? new Product();
			Categories = await Context.Category.ToDictionaryAsync(c => c.CategoryName, c => (int?)c.CategoryId);
			Manufacturers = await Context.Manufacturer.ToDictionaryAsync(m => $"{m.ManufacturerName}, {m.ManufacturerInc}", m => (int?)m.ManufacturerId);
		}
	}
}

이어서 Product개체의 Category와 Manufacturer정보를 확인하기 위해 Blazor/Forms folder의 FormSpy.razor file을 변경하여 해당 속성의 값을 추가합니다.

<div class="container-fluid no-gutters">
	<div class="row">
		<div class="col">
			@ChildContent
		</div>
		<div class="col">
			<table class="table table-sm table-striped table-bordered">
				<thead>
					<tr><th colspan="2" class="text-center">Data Summary</th></tr>
				</thead>
				<tbody>
					<tr><th>ID</th><td>@ProductData?.ProductId</td></tr>
					<tr><th>Name</th><td>@ProductData?.ProductName</td></tr>
					<tr><th>Prirce</th><td>@ProductData?.ProductPrice</td></tr>
					<tr><th>Category</th><td>@ProductData?.ProductCategoryId</td></tr>
					<tr><th>Manufacturer</th><td>@ProductData?.ProductManufacturerId</td></tr>
				</tbody>
			</table>
		</div>
	</div>
</div>

예제에서는 Entity Framework Core ToDictionaryAsync method를 사용해 값에 대한 collection과 Category와 Manufacturer data로부터 label을 생성하고 다시 이들을 사용해 CustomSelect component를 설정하였습니다. project를 실행하고 /forms/edit/2로 URL을 요청하면 다음과 같이 select요소를 볼 수 있으며

여기서 새로운 값을 선택하게 되면 CustomSelect component는 TryParseValueFromString method를 호출해 result로 들어올 CurrentValueAsString속성을 update 하게 됩니다. 그리고 해당 result는 다시 Value의 binding을 update 하는 데 사용되어 다음과 같은 응답을 생성하게 됩니다.

(2) Form Data 유효성 검증

 

Blazor는 표준 속성을 사용해 validation을 수행하는 아래 표의 component를 제공하고 있습니다.

DataAnnotationsValidator 해당 component는 Blazor form기능의 model class에 적용된 validation attribute를 통합니다.
ValidationMessage 해당 component는 단일 속성에 대한 validation error message를 표시합니다.
ValidationSummary 해당 component는 전체 model개체에 대한 validation error message를 표시합니다.

validation component는 아래 표의 CSS class로 할당된 요소를 생성하여 사용자의 주의력을 집중할 수 있도록 합니다.

validation-errors ValidationSummary component는 해당 class로 할당되고 validation message의 상위수준 container인 ul요소를 생성합니다.
validation-message ValidationSummary component는 해당 class에 할당된 li요소를 통해 ul요소를 채우게 되며 또한 ValidationMessage component는 속성 수준 message에 대한 해당 class에 할당된 div요소를 render합니다.

Blazor Input요소는 생성한 HTML요소를 아래 표의 class에 추가하여 validation상태를 나타냅니다. 이것은 CustomSelect component를 파생한 InputBase<TValue> class를 포함하며 CssClass속성의 값이기도 합니다.

modified 사용자가 값을 편집하면 요소가 해당 class에 추가됩니다.
valid 포함된 값이 유효성검사를 통과하면 요소가 해당 class에 추가됩니다.
invalid 포함된 값이 유횽성검사에 실패하면 요소가 해당 class에 추가됩니다.

해당 component와 class의 결합이 처음에는 혼란스러울 수 있으나 상기표의 class들에 기반해 필요한 CSS style을 정의함으로서 시작해 보면 이해기 쉬울 것입니다. wwwroot folder에 blazorValidation.css이름의 CSS Stylesheet file을 아래와 같이 추가합니다.

.validation-errors {
    background-color: rgb(220, 53, 69);
    color: white;
    padding: 8px;
    text-align: center;
    font-size: 16px;
    font-weight: 500;
}

div.validation-message {
    color: rgb(220, 53, 69);
    font-weight: 500
}

.modified.valid {
    border: solid 3px rgb(40, 167, 69);
}

.modified.invalid {
    border: solid 3px rgb(220, 53, 69);
}

해당 style은 error message를 붉은색으로 표시하며 붉은색 또는 녹색 border를 각각의 form요소에 적용합니다. 아래 예제는 Blazor/Forms folder의 Editor.razor file을 변경한 것으로 위의 CSS stylesheet를 imprt 하고 Blazor validation component를 적용하였습니다.

@page "/forms/edit/{id:int}"

@layout EmptyLayout

<link href="/blazorValidation.css" rel="stylesheet" />

<h4 class="bg-primary text-center text-white p-2">Edit</h4>
<FormSpy ProductData="ProductData">
	<EditForm Model="ProductData">
		<DataAnnotationsValidator />
		<ValidationSummary />
		<div class="form-group">
			<label>Product ID</label>
			<InputNumber class="form-control" @bind-Value="ProductData.ProductId" disabled />
		</div>
		<div class="form-group">
			<label>ProductName</label>
			<ValidationMessage For="@(() => ProductData.ProductName)" />
			<InputText class="form-control" @bind-Value="ProductData.ProductName" />
		</div>
		<div class="form-group">
			<label>Price</label>
			<ValidationMessage For="@(() => ProductData.ProductPrice)" />
			<InputNumber class="form-control" @bind-Value="ProductData.ProductPrice" />
		</div>
		<div class="form-group">
			<label>Category ID</label>
			<ValidationMessage For="@(() => ProductData.ProductCategoryId)" />
			<CustomSelect TValue="int?" Values="Categories" Parser="@((string str) => int.Parse(str))" @bind-Value="ProductData.ProductCategoryId">
				<option selected disabled value="0">Choose a Category</option>
			</CustomSelect>
		</div>
		<div class="form-group">
			<label>Manufacturer ID</label>
			<ValidationMessage For="@(() => ProductData.ProductManufacturerId)" />
			<CustomSelect TValue="int?" Values="Manufacturers" Parser="@((string str) => int.Parse(str))" @bind-Value="ProductData.ProductManufacturerId">
				<option selected disabled value="0">Choose a Manufacturer</option>
			</CustomSelect>
		</div>
		<div class="text-center">
			<NavLink class="btn btn-secondary" href="/forms">Back</NavLink>
		</div>
	</EditForm>
</FormSpy>

DataAnnotationsValidator과 ValidationSummary component는 어떠한 설정 attribute도 없이 적용되었습니다. ValidationMessage attribute는 For attribute를 사용해 설정되며 component가 나타내는 속성을 반환하는 함수를 수신하는데 예를 들어 아래 표현식은 ProductName을 선택하는 것입니다.

<ValidationMessage For="@(() => ProductData.ProductName)" />

해당 표현식은 어떠한 매개변수도 정의하지 않으며 model type이 아닌 EditForm component의 Model attribute에서 사용되는 개체로 부터의 속성을 선택하고 있습니다. 다시 말해 Product class가 아닌 ProductData개체하에서 표현식이 동작한다는 것을 의미하는 것입니다.

Blazor는 항상 ValidationMessage component를 위해 속성의 type을 결정하지는 못합니다. 만약 예외를 받은 경우라면 TValue attribute를 추가하여 type을 명시적으로 설정할 수 있습니다. 예를 들어 ValidationMessage component가 나타내는 속성의 유형이 long이라면 TValue="long" attribute를 추가합니다.

이제 data validation을 사용하기 위한 마지막 절차로 Models folder의 Product.cs file에서 처럼 model class에 attribute적용합니다.

public partial class Product
{
    public int ProductId { get; set; }

	[Required(ErrorMessage = "A ProductName is required")]
	[MinLength(3, ErrorMessage = "ProductName must be 3 or more characters")]
	public string ProductName { get; set; }

	[Required(ErrorMessage = "A ProductPrice is required")]
	[Range(1, 100000, ErrorMessage = "ProductPrice must be 1 or more number")]
	public int? ProductPrice { get; set; }

	[Range(1, long.MaxValue, ErrorMessage = "A ProductCategory must be selected")]
	public int? ProductCategoryId { get; set; }

	[Range(1, long.MaxValue, ErrorMessage = "A ProductManufacturer must be selected")]
	public int? ProductManufacturerId { get; set; }

    public virtual Category ProductCategory { get; set; }

    public virtual Manufacturer ProductManufacturer { get; set; }
}

validation component의 결과를 확인해 보기 위해 project를 실행하고 /forms/edit/2 URL을 요청합니다. ProductName의 값을 지우고 focus를 다른 input field로 이동하면 validation이 수행되어 error message가 표시될 것입니다. 이때 Editor component는 summary와 속성 message를 둘 다 표시함으로써 같은 error message를 이중으로 표시하는 것을 볼 수 있습니다. 또한 ProductPrice에서 0 값을 입력하고 focus를 이동하면 두 번째 valudation error message가 다음과 같이 표시될 것입니다.

다른 속성에 대한 validation도 지원하기는 하지만 select요소는 사용자가 잘못된 값을 선택할 수 있는 여지를 주지 않습니다. 만약 해당 속성에 대한 값을 바꾸게 되면 select요소는 녹색 border로 표시되어 올바른 값이 선택되었음을 나타낼 것입니다.

 

(3) Form Event 처리하기

 

EditForm component는 사용자가 어떤 action을 취할 수 있도록 application이 응답할 수 있는 event를 아래 표와 같이 정의하고 있습니다.

OnValidSubmit 해당 event는 form이 submit되고 form data가 validation을 통과하면 발생합니다.
OnInvalidSubmit 해당 event는 form이 submit되고 form data가 validation에 실패하면 발생합니다.
OnSubmit 해당 event는 form이 submit되고 validation이 수행되기 전에 발생합니다.

해당 event들은 EditFrom component에 포함된 content안에 submit button을 추가함으로서 발생시킬 수 있고 EditForm component는 render 한 form요소에서 보낸 onsubmit event를 처리하고 validation을 적용하며, 위 표의 event를 발생시킵니다. 아래 예제는 Blazor/Forms folder의 Editor.razor file을 변경한 것으로 Editor component에 submit button을 추가하여 EditForm event를 처리하도록 하고 있습니다.

@page "/forms/edit/{id:int}"

@layout EmptyLayout

<link href="/blazorValidation.css" rel="stylesheet" />

<h4 class="bg-primary text-center text-white p-2">Edit</h4>
<h6 class="bg-info text-center text-white p-2">@FormSubmitMessage</h6>

<FormSpy ProductData="ProductData" OnValidSubmit="HandleValidSubmit" OnInvalidSubmit="HandleInvalidSubmit">
	<EditForm Model="ProductData">
		<DataAnnotationsValidator />
		<ValidationSummary />
		<div class="form-group">
			<label>Product ID</label>
			<InputNumber class="form-control" @bind-Value="ProductData.ProductId" disabled />
		</div>
		<div class="form-group">
			<label>ProductName</label>
			<ValidationMessage For="@(() => ProductData.ProductName)" />
			<InputText class="form-control" @bind-Value="ProductData.ProductName" />
		</div>
		<div class="form-group">
			<label>Price</label>
			<ValidationMessage For="@(() => ProductData.ProductPrice)" />
			<InputNumber class="form-control" @bind-Value="ProductData.ProductPrice" />
		</div>
		<div class="form-group">
			<label>Category ID</label>
			<ValidationMessage For="@(() => ProductData.ProductCategoryId)" />
			<CustomSelect TValue="int?" Values="Categories" Parser="@((string str) => int.Parse(str))" @bind-Value="ProductData.ProductCategoryId">
				<option selected disabled value="0">Choose a Category</option>
			</CustomSelect>
		</div>
		<div class="form-group">
			<label>Manufacturer ID</label>
			<ValidationMessage For="@(() => ProductData.ProductManufacturerId)" />
			<CustomSelect TValue="int?" Values="Manufacturers" Parser="@((string str) => int.Parse(str))" @bind-Value="ProductData.ProductManufacturerId">
				<option selected disabled value="0">Choose a Manufacturer</option>
			</CustomSelect>
		</div>
		<div class="text-center">
			<button type="submit" class="btn btn-primary mt-2">Submit</button>
			<NavLink class="btn btn-secondary" href="/forms">Back</NavLink>
		</div>
	</EditForm>
</FormSpy>

@code {
	[Inject]
	public NavigationManager? NavManager { get; set; }

	[Inject]
	BlazorTDBContext? Context { get; set; }

	[Parameter]
	public int Id { get; set; }

	public Product ProductData { get; set; } = new();

	public IDictionary<string, int?> Categories { get; set; } = new Dictionary<string, int?>();
	public IDictionary<string, int?> Manufacturers { get; set; } = new Dictionary<string, int?>();

	protected async override Task OnParametersSetAsync()
	{
		if (Context != null)
		{
			ProductData = await Context.Product.Where(x => x.ProductId == Id).FirstOrDefaultAsync() ?? new Product();
			Categories = await Context.Category.ToDictionaryAsync(c => c.CategoryName, c => (int?)c.CategoryId);
			Manufacturers = await Context.Manufacturer.ToDictionaryAsync(m => $"{m.ManufacturerName}, {m.ManufacturerInc}", m => (int?)m.ManufacturerId);
		}
	}

	public string FormSubmitMessage { get; set; } = "Form Data Not Submitted";
	public void HandleValidSubmit() => FormSubmitMessage = "Valid Data Submitted";
	public void HandleInvalidSubmit() => FormSubmitMessage = "Invalid Data Submitted";
}

project를 실행하고 /forms/edit/2로 URL을 요청합니다. ProductName field를 삭제한 뒤 Submit button을 눌러보면 validation error와 더불어 form이 잘못된 data를 submit 하였음을 나타내는 message를 다음과 같이 보게 될 것입니다.

그리고 다시 ProductName을 채우고 Price에 적당한 값을 입력한뒤 submit button을 click 하면 Form의 상태는 다음과 같이 바뀌게 됩니다.

3. Blazor에서 Entity Framework Core사용

 

Blazor model은 Entity Framework Core의 동작을 변경함으로 종래의 ASP.NET Core application을 작성하는데 익숙해지면 예상치 못한 결과를 발생시킬 수 있습니다. 따라서 이러한 문제와 관련하여 발생할 수 있는 문제점과 해당 문제점을 어떻게 피할 수 있는지를 알아볼 필요가 있습니다.

 

(1) Entity Framework Core Context 범위 관련 쟁점

 

첫번째 문제점을 확인해 보기 위해 project를 실행하고 /forms/edit/4 URL을 요청합니다. ProductName field의 내용을 삭제하고 Price의 값을 0으로 입력한 다음 Tab key를 눌러 focus를 변경합니다.

 

2개의 입력값 모두 validation을 거치면서 다음과 같은 오류를 보게 될 것입니다.

이 상태에서 Back button을 click 해 보면 다음과 같이 잘못된 값을 입력했음에도 불구하고 이전에 변경한 사항이 그대로 반영되어 있음을 확인할 수 있습니다.

controller 또는 Razor Page를 통해 작성된 종전 ASP.NET Core application에서는 button을 click 하면 새로운 HTTP요청이 발생합니다. 각 요청은 개별적으로 처리되고 개별적으로 scoped service로서 설정된 자체 Entity Framework Core context 개체를 수신받습니다. 결과적으로 한 요청을 처리할 때 생성된 data는 database에 적용된 경우에만 다른 요청에 영향을 주게 됩니다.

 

Blazor Application에서 routing system은 새로운 HTTP요청을 보내지 않고 URL변경에 대응합니다. 이는 다수의 component가 단지 Blazor가 server를 관리하는 지속적 HTTP연결을 통해서만 표시됨을 의미하는데 그 결과 단일 의존성 주입 scope가 여러 component에서 다음과 같이 공유되고 있고 이로서 하나의 component에서의 변경사항이 database에 반영되지 않았더라도 다른 component에 영향을 주게 되는 것입니다.

이는 component가 data를 최소 ASP.NET Core와 같이 처리할 것이라고 예상하는 개발자들에게는 예상하지 못한 동작을 유발할 수 있는 위험성이 존재합니다.

 

● 저장되지 않은 data변경사항 폐기하기

 

component사이에 context를 공유하는 것이 매력적이라면 이러한 접근법을 사용해 component가 파기될때 모든 변경사항을 Blazor/Forms folder의 Editor.razor file을 변경한 아래 예제와 같이 폐기하도록 할 수 있습니다.

public void HandleInvalidSubmit() => FormSubmitMessage = "Invalid Data Submitted";

public void Dispose()
{
	if (Context != null)
	{
		Context.Entry(ProductData).State = EntityState.Detached;
	}
}

component는 System.IDisposable interface를 구현할 수 있으며 Dispose method는 다른 component로 이동할 때와 같은 상황에서 component가 파기될 때 호출됩니다. 위 예제에서는 Dispose method를 구현하여 Entity Framework Core에게 ProductData개체를 무시할 수 있도록 설정하고 있는데 이 것은 향후 요청을 충족시키는 데 사용하지 않을 것임을 의미합니다. 예제의 효과를 확인해 보기 위해 project를 실행하고 /forms/edit/4 URL을 요청합니다. 그리고 ProductName field를 삭제하고 Back Button을 click 해 보면 Entity Framework Core가 해당 data를 통해 List component를 제공할 때 수정된 Product개체가 무시되었음을 다음과 같이 확인할 수 있습니다.

● 새로운 의존성 주입 Scope생성

 

나머지 ASP.NET Core에서 사용하는 model을 보존하고 각 component에서 자체적인 Entity Framework Core context 개체를 수신하고자 한다면 새로운 의존성 주입 scope를 생성해야 합니다. 그리고 이것은 component의 기반 class를 OwningComponentBase혹은 OwningComponentBase<T>로 설정하기 위해 @inherits표현식을 사용함으로써 구현할 수 있습니다.

 

OwningComponentCase class는 IServiceProvider개체를 제공하고 있는 component에서 상속되어 ScopedServices속성을 정의하고 있습니다. IServiceProvider개체는 component의 생명주기를 특정하는 scope안에서 생성되며 다른 component와 공유되지 않는 service를 가져오기 위해 사용될 수 있습니다. 아래 예제는 Blazor/Forms folder의 Editor.razor file을 변경하여 새로운 scope를 사용하도록 한 것입니다.

@page "/forms/edit/{id:int}"

@layout EmptyLayout
@inherits OwningComponentBase
@using Microsoft.Extensions.DependencyInjection

..생략

@code {
	[Inject]
	public NavigationManager? NavManager { get; set; }

	//[Inject]
	BlazorTDBContext? Context => ScopedServices.GetService<BlazorTDBContext>();

	..생략
    
	public void HandleInvalidSubmit() => FormSubmitMessage = "Invalid Data Submitted";

	//public void Dispose()
	//{
	//	if (Context != null)
	//	{
	//		Context.Entry(ProductData).State = EntityState.Detached;
	//	}
	//}
}

예제에서는 Inject attribute를 주석처리하고 Context속성의 값을 DataContext service로 설정하고 있습니다. Microsoft.Extensions.DependencyInjection namespace에서는 IServiceProvider개체에서 service를 쉽게 가져올 수 있도록 하는 확장 method를 포함하고 있습니다.

기반 class를 변경하는 것은 Inject attribute를 사용해 수신된 service에 영향을 주지 않으며 여전히 요청 scope안에서 얻게 될 것입니다. 전용 component의 scope안에서 필요한 각 service는 ScopedServices속성을 통해 가져와야 하며 Inject attribute는 속성에 적용되어서는 안 됩니다.

OwningComponentBase<T> class는 추가적인 편의 속성을 정의하여 type T에 대한 scoped service로의 접근을 제공하며 component가 Blazor/Forms folder의 Editor.razor file에서 typed기반 class를 사용한 것처럼 단일 scoped service만을 필요로 하는 경우 유용하게 사용될 수 있습니다. (비록 ScopedServices속성을 통해 추가 service를 여전히 가져올 수 있지만)

@page "/forms/edit/{id:int}"

@layout EmptyLayout
@inherits OwningComponentBase<BlazorTDBContext>

..생략

@code {
	[Inject]
	public NavigationManager? NavManager { get; set; }

	//[Inject]
	BlazorTDBContext? Context => Service;
	
    ..생략
}

scoped service는 Service라는 이름의 속성을 통해 사용할 수 있으며 예제에서는 기반 class의 type인수로 BlazorTDBContext를 지정하였습니다.

 

어떤 기반 class가 사용되든 관계없이 결과적으로는 Editor component는 자체 의존성 주입 scope와 자체 BlazorTDBContext를 가지게 됩니다. List component는 바뀌지 않았으므로 요청 scoped BlazorTDBContext를 아래 그림과 같이 받게 됩니다.

project를 실행하고 /forms/edit/4로 URL을 요청합니다. ProductName field를 삭제하고 Back Button을 click 하면 Editor component에 의해 만들어진 변경사항은 database에 저장되지 않습니다. 이는 Editor component의 data context가 List component에서 사용된 것으로부터 분리되었으므로 변경된 data를 폐기되었고 결국 이전과 같은 응답을 생성하게 됩니다.

 

(2) 반복적인 Query 문제

 

Blazor는 가능한 한 효휼적으로 상태변화에 응답하지만 여전히 Browser로 전송해야 할 변경사항을 확인하기 위해 component의 content를 render 해야 합니다.

 

Blaozar가 작동하는 방식 중 하나의 결과로 database로 전송할 query의 수가 증가하는 형태를 가져올 수 있습니다. 이러한 사항을 확인해 보기 위해 Blazor/Forms folder의 List.razor file에서와 같이 List component의 Counter값을 증가시키는 button을 추가합니다.

<button class="btn btn-primary" @onclick="@(() => Counter++)">Increment</button>
<span class="h5">Counter: @Counter</span>

@code {
	[Inject]
	public BlazorTDBContext? Context { get; set; }

	public IEnumerable<Product> Products { get; set; } = Enumerable.Empty<Product>();

	protected override void OnInitialized()
	{
		Products = Context?.Product?.Include(p => p.ProductCategory).Include(p => p.ProductManufacturer) ?? Enumerable.Empty<Product>();
	}

	string GetEditUrl(long id) => $"/forms/edit/{id}";

	public int Counter { get; set; } = 0;
}

project를 실행하고 /forms으로 URL을 요청한 뒤 해당 button을 click 하여 ASP.NET Core server로부터 되돌아오는 응답을 확인합니다. 아마도 button을 click 할 때마다 event handler가 호출되어 새로운 database query가 database로 전송되고 있음을 아래와 같이 생성되는 logging message를 통해 알 수 있습니다.

component가 render 될 때마다 사실상 어떠한 data관련 처리도 필요하지 않은 상황임에도 불구하고 Entity Framework Code는 2개로 나뉘는 요청을 database로 전송하고 있습니다.

 

이러한 현상은 Entity Framework Core를 사용될 때마다 발생할 수 있으며 Blazor에 의해서도 더 심해질 수 있습니다. 비록 database query를 IEnumerable<T>속성으로 할당하는 것이 일반적이기는 하지만 이것은 Entity Framework Core의 중요한 하나의 측면을 숨기는 것으로 LINQ표현식은 query의 표현식일 뿐 결과가 아니며 속성을 읽을 때마다 새로운 query가 database로 전송됩니다. 예제에서 Product 속성은 List component에 의해 2번씩 읽히게 되며 하나는 Counter속성의 의한 것으로 data가 load 되었는지 여부를 확인하는 것이고 다른 하나는 HTML table에서 row를 생성하기 위한 @foreach표현식에 해당합니다. 사용자가 Increment button을 click 하면 Blazor는 List component를 render 하고 변경사항이 무엇인지를 다시 확인하게 되는데 이것이 Product속성을 이중으로 읽고 2번의 추가적인 database query를 생성하게 되는 것입니다.

 

Blazor와 Entity Framework Core는 둘 다 할 수 있는 작업을 그래도 수행하는 것일 뿐이며 Blazor는 browser로 전송해야 할 HTML의 변경사항이 무엇인지를 알아내기 위해 component의 출력을 render 해야 합니다. button이 click 되면 요소가 render 되고 모든 Razor표현식이 실행되기 전까지는 click자체가 어떤 효과가 있는지는 알 수 있는 방법이 없습니다. Entity Framework Core는 속성이 읽힐 때마다 query를 실행하여 application이 항상 최신의 data를 유지할 수 있도록 합니다.

 

이 기능에 대한 결합은 2가지 문제를 유발하게 됩니다. 첫 번째는 불필요한 query가 database로 전송됨으로써 application에서 필요한 가용성을 늘릴 수 있다는 것입니다.(비록 database server가 query를 처리하는데 최적화된 server이므로 항상 그렇지는 않지만)

 

두 번째 문제는 database로의 변경사항이 아무 관련이 없는 상호작용을 만든 후에야 사용자에게 제공된 content에 반영될 것이라는 것입니다. 만약 다른 사용자가 Product개체를 database로 추가했다면 이것은 사용자가 Increment button을 click 하고 나서야 table에 나타나게 됩니다. 통상 사용자는 application이 자신들의 action을 통해서만 반영된다고 예상하는데 예상하지 못한 변경사항은 사용자를 혼란스럽고 산만하게 만들 수 있습니다.

 

● Component의 Query관리

 

Blazor와 Entity Framework Core와의 상호작용이 모든 project에서 문제가 되지는 않겠지만 만약 그렇다면 가장 좋은 방법은 database에 질의하고 사용자가 update가 발생할것라고 예상하는 부분에서만 재질의를 수행하는 것입니다. 몇몇 application에서는 아마도 아래 Blazor/Forms folder의 List.razor file에서 처럼 사용자에게 data를 다시 load 하기 위한 명시적인 option을 제공해야 할 수 있는데 사용자가 보고 싶어 하며 update가 발생할 가능성이 있는 application의 경우 더욱 그렇습니다.

<button class="btn btn-danger" @onclick="UpdateData">Update</button>

<button class="btn btn-primary" @onclick="@(() => Counter++)">Increment</button>
<span class="h5">Counter: @Counter</span>

@code {
	[Inject]
	public BlazorTDBContext? Context { get; set; }

	public IEnumerable<Product> Products { get; set; } = Enumerable.Empty<Product>();

	protected async override Task OnInitializedAsync()
	{
		await UpdateData();
	}

	private async Task UpdateData()
	{
		if (Context != null)
		{
			Products = await Context.Product.Include(p => p.ProductCategory).Include(p => p.ProductManufacturer).ToListAsync<Product>();
		}
		else
		{
			Products = Enumerable.Empty<Product>();
		}
	}

	string GetEditUrl(long id) => $"/forms/edit/{id}";

	public int Counter { get; set; } = 0;
}

Update method는 같은 질의를 수행하지만 ToListAsync method를 적용하여 Entify Framework Core query의 평가를 강제합니다. 결과적으로 Products속성에 할당되고 추가적인 질의를 수행하지 않으면서 반복적으로 값을 읽을 수 있게 되었고 사용자 control에 data를 제공하기 위해 click 할 때 UpdateData method를 호출하는 button을 추가하였습니다. project를 실행하고 /forms URL을 요청한 뒤 Increment button을 click 합니다. ASP.NET Core server로부터의 log를 확인해 보면 query는 component가 초기화될 때만 만들어진다는 것을 확인할 수 있습니다. 따라서 명시적으로 trigger 하기 위해서는 Update button을 click 해야 합니다.

 

일부 동작과정에서 새로운 query가 필요할 수 있는데 이것도 쉽게 수행할 수 있습니다. 아래 예제는 Blazor/Forms folder의 List.razor file을 변경한 것으로 List component에 정렬동작을 추가한 것입니다.

<button class="btn btn-danger" @onclick="UpdateData">Update</button>
<button class="btn btn-info my-2" @onclick="SortWithQuery">Sort (With Query)</button>
<button class="btn btn-info my-2" @onclick="SortWithoutQuery">Sort (No Query)</button>

<button class="btn btn-primary" @onclick="@(() => Counter++)">Increment</button>
<span class="h5">Counter: @Counter</span>

@code {
	[Inject]
	public BlazorTDBContext? Context { get; set; }

	public IEnumerable<Product> Products { get; set; } = Enumerable.Empty<Product>();

	protected async override Task OnInitializedAsync()
	{
		await UpdateData();
	}

	private async Task UpdateData()
	{
		if (Context != null)
		{
			Products = await Context.Product.Include(p => p.ProductCategory).Include(p => p.ProductManufacturer).ToListAsync<Product>();
		}
		else
		{
			Products = Enumerable.Empty<Product>();
		}
	}

	private IQueryable<Product> Query => Context!.Product.Include(p => p.ProductCategory).Include(p => p.ProductManufacturer);
	private async Task UpdateData(IQueryable<Product>? query = null) => Products = await (query ?? Query).ToListAsync<Product>();
	public async Task SortWithQuery()
	{
		await UpdateData(Query.OrderBy(p => p.ProductName));
	}
	public void SortWithoutQuery()
	{
		Products = Products.OrderBy(p => p.ProductName).ToList<Product>();
	}

	string GetEditUrl(long id) => $"/forms/edit/{id}";

	public int Counter { get; set; } = 0;
}

Entity Framework Core query는 IQueryable<T>개체로서 표현되며 Database server로 보내기 전 추가적인 LINQ method를 통해 query가 구성됩니다. 예제의 새로운 동작은 LINQ OrderBy method를 둘 다 사용하지만 하나는 IQueryable<T>에 적용되어 있는데 이로 인해 ToListAsync method로 query를 전송하도록 평가됩니다. 이미 존재하는 결과 data에 OrderBy method를 적용한 다른 동작은 새로운 질의를 보내는 것 없이 sorting을 수행합니다. 2개의 동작에 대한 결과를 확인해 보기 위해 project를 실행하고 /forms로 URL을 요청한 뒤 Sort button을 click 합니다. 여기서 Sort(With Query) button을 click 되면 database로 전송된 query를 log message를 통해 볼 수 있습니다.

중복되는 query에 대한 예외 회피하기
만약 이전 동작이 완료되기도 전에 두 번째 동작이 해당 context에서 시작되려고 한다면 아마도 예외를 마주하게 될지도 모릅니다. 이는 하위 component가 OnParametersSetAsync method를 비동기 Entity Framework Core query를 수행하기 위해 사용하고 상위 data에 대한 변경사항이 query가 완료되기도 전에 OnParametersSetAsync로의 두번째 호출을 trigger 할때 발생합니다. 여기서 두번째 method는 예외를 유발시키는 중복되는 query를 시작하도록 호출되는 것입니다. 이러한 문제는 Entity Framework Core를 동기적으로 수행함으로서 해결될 수 있습니다. 상위 component는 data를 수신하게 되면 update를 trigger할 수 있으므로 query는 동기적으로 수행되어야 합니다.

 

4. CRUD 동작 수행하기

 

위에서 설명된 기능이 어떻게 결합될 수 있는지를 보기 위해 사용자가 Product개체에 대해 CRUD동작을 수행할 수 있는 간단한 application을 생성할 것입니다.

 

(1) List Component 생성

 

List component는 여기서 필요한 기본적인 기능을 포함하고 있습니다. 아래 예제는 Blazor/Forms folder에서 List.razor file을 변경한 것으로 더 이상 필요하지 않은 이전에 몇몇 기능을 제거하고 사용자가 특정 기능을 수행할 수 있는 button을 추가하였습니다.

@page "/forms"
@page "/forms/list"
@layout EmptyLayout
@inherits OwningComponentBase<BlazorTDBContext>

<h5 class="bg-primary text-white text-center p-2">Product</h5>
<table class="table table-sm table-striped table-bordered">
	<thead>
		<tr>
			<th>ID</th>
			<th>Name</th>
			<th>Category</th>
			<th>Manufactorer</th>
			<th></th>
		</tr>
	</thead>
	<tbody>
		@if (Products.Count() == 0)
		{
			<tr><th colspan="5" class="p-4 text-center">Loading Data...</th></tr>
		}
		else
		{
			@foreach (Product p in Products)
			{
				<tr>
					<td>@p.ProductId</td>
					<td>@p.ProductName</td>
					<td>@p.ProductCategory.CategoryName</td>
					<td>@p.ProductManufacturer.ManufacturerName</td>
					<td class="text-center">
						<NavLink class="btn btn-sm btn-info" href="@GetDetailsUrl(p.ProductId)">
							Details
						</NavLink>
						<NavLink class="btn btn-sm btn-warning" href="@GetEditUrl(p.ProductId)">
							Edit
						</NavLink>
						<button class="btn btn-sm btn-danger" @onclick="@(() => HandleDelete(p))">
							Delete
						</button>
					</td>
				</tr>
			}
		}
	</tbody>
</table>

<NavLink class="btn btn-primary" href="/forms/create">Create</NavLink>

@code {
	//[Inject]
	public BlazorTDBContext? Context => Service;

	public IEnumerable<Product> Products { get; set; } = Enumerable.Empty<Product>();

	protected async override Task OnInitializedAsync()
	{
		await UpdateData();
	}

	private async Task UpdateData()
	{
		if (Context != null)
		{
			Products = await Context.Product.Include(p => p.ProductCategory).Include(p => p.ProductManufacturer).ToListAsync<Product>();
		}
		else
		{
			Products = Enumerable.Empty<Product>();
		}
	}

	private IQueryable<Product> Query => Context!.Product.Include(p => p.ProductCategory).Include(p => p.ProductManufacturer);
	private async Task UpdateData(IQueryable<Product>? query = null) => Products = await (query ?? Query).ToListAsync<Product>();
	public async Task SortWithQuery()
	{
		await UpdateData(Query.OrderBy(p => p.ProductName));
	}
	public void SortWithoutQuery()
	{
		Products = Products.OrderBy(p => p.ProductName).ToList<Product>();
	}

	string GetEditUrl(long id) => $"/forms/edit/{id}";

	string GetDetailsUrl(long id) => $"/forms/details/{id}";
	public async Task HandleDelete(Product p)
	{
		if (Context != null)
		{
			Context.Remove(p);
			await Context.SaveChangesAsync();
			await UpdateData();
		}
	}
}

개체의 정보를 보고 생성하고 편집하는 동작은 다른 URL로 이동하는 것으로 처리되지만 삭제동작은 List component에서 수행되므로 변경사항을 사용자에게 반영하기 위해 변경사항이 저장된 후 data를 다시 읽어 들이도록 주의해야 합니다.

 

(2) Detail Component 생성

 

detail component는 말 그대로 data의 상세를 표시하는 것으로서 Blazor form기능이 필요하지 않으며 Entity Framework Core에 대한 어떠한 문제도 발생하지 않습니다. 해당 기능을 추가하기 위해 Blazor/Forms folder에 Details.razor이름의 Blazor component를 아래와 같이 추가합니다.

@page "/forms/details/{id:long}"
@layout EmptyLayout
@inherits OwningComponentBase<BlazorTDBContext>

<h4 class="bg-info text-center text-white p-2">Details</h4>
<div class="form-group">
	<label>ID</label>
	<input class="form-control" value="@ProductData.ProductId" disabled />
</div>
<div class="form-group">
	<label>Firstname</label>
	<input class="form-control" value="@ProductData.ProductName" disabled />
</div>
<div class="form-group">
	<label>Surname</label>
	<input class="form-control" value="@ProductData.ProductPrice" disabled />
</div>
<div class="form-group">
	<label>Department</label>
	<input class="form-control" value="@ProductData.ProductCategory?.CategoryName" disabled />
</div>
<div class="form-group">
	<label>Location</label>
	<input class="form-control" value="@($"{ProductData.ProductManufacturer?.ManufacturerName}, {ProductData.ProductManufacturer?.ManufacturerInc}")" disabled />
</div>
<div class="text-center p-2">
	<NavLink class="btn btn-info" href="@EditUrl">Edit</NavLink>
	<NavLink class="btn btn-secondary" href="/forms">Back</NavLink>
</div>

@code {
	[Inject]
	public NavigationManager? NavManager { get; set; }

	BlazorTDBContext Context => Service;

	[Parameter]
	public long Id { get; set; }

	public Product ProductData { get; set; } = new();

	protected async override Task OnParametersSetAsync()
	{
		if (Context != null)
			ProductData = await Context.Product.Include(p => p.ProductCategory).Include(p => p.ProductManufacturer).FirstOrDefaultAsync(p => p.ProductId == Id) ?? new();
	}

	public string EditUrl => $"/forms/edit/{Id}";
}

component에서 표시되는 모든 입력요소는 비활성상태가 되므로 event의 처리나 사용자 입력에 대한 처리가 필요하지 않습니다.

 

(3) Editor Component 생성

 

이제 남겨진 기능은 Editor component에서 처리될 것입니다. 아래 예제는 Blazor/Forms folder의 Editor.razor file을 변경한 것으로 더 이상 필요하지 않은 부분을 삭제하고 daa의 지속성을 포함해 개체를 생성하고 수정하기 위한 기능을 추가하였습니다.

@page "/forms/edit/{id:int}"

@page "/forms/create"
@layout EmptyLayout
@inherits OwningComponentBase<BlazorTDBContext>

<link href="/blazorValidation.css" rel="stylesheet" />

<h4 class="bg-@Theme text-center text-white p-2">@Mode</h4>

<FormSpy ProductData="ProductData">
	<EditForm Model="ProductData" OnValidSubmit="HandleValidSubmit" OnInvalidSubmit="HandleValidSubmit">
		<DataAnnotationsValidator />
		
		@if (Mode == "Edit")
		{
			<div class="form-group">
				<label>Product ID</label>
				<InputNumber class="form-control" @bind-Value="ProductData.ProductId" readonly />
			</div>
		}
		<div class="form-group">
			<label>ProductName</label>
			<ValidationMessage For="@(() => ProductData.ProductName)" />
			<InputText class="form-control" @bind-Value="ProductData.ProductName" />
		</div>
		<div class="form-group">
			<label>Price</label>
			<ValidationMessage For="@(() => ProductData.ProductPrice)" />
			<InputNumber class="form-control" @bind-Value="ProductData.ProductPrice" />
		</div>
		<div class="form-group">
			<label>Category ID</label>
			<ValidationMessage For="@(() => ProductData.ProductCategoryId)" />
			<CustomSelect TValue="int?" Values="Categories" Parser="@((string str) => int.Parse(str))" @bind-Value="ProductData.ProductCategoryId">
				<option selected disabled value="0">Choose a Category</option>
			</CustomSelect>
		</div>
		<div class="form-group">
			<label>Manufacturer ID</label>
			<ValidationMessage For="@(() => ProductData.ProductManufacturerId)" />
			<CustomSelect TValue="int?" Values="Manufacturers" Parser="@((string str) => int.Parse(str))" @bind-Value="ProductData.ProductManufacturerId">
				<option selected disabled value="0">Choose a Manufacturer</option>
			</CustomSelect>
		</div>
		<div class="text-center">
			<button type="submit" class="btn btn-primary mt-2">Save</button>
			<NavLink class="btn btn-secondary" href="/forms">Back</NavLink>
		</div>
	</EditForm>
</FormSpy>

@code {
	[Inject]
	public NavigationManager? NavManager { get; set; }

	//[Inject]
	BlazorTDBContext? Context => Service;

	[Parameter]
	public int Id { get; set; }

	public Product ProductData { get; set; } = new();

	public IDictionary<string, int?> Categories { get; set; } = new Dictionary<string, int?>();
	public IDictionary<string, int?> Manufacturers { get; set; } = new Dictionary<string, int?>();

	protected async override Task OnParametersSetAsync()
	{
		if (Context != null)
		{
			if (Mode == "Edit")
				ProductData = await Context.Product.Where(x => x.ProductId == Id).FirstOrDefaultAsync() ?? new Product();

			Categories = await Context.Category.ToDictionaryAsync(c => c.CategoryName, c => (int?)c.CategoryId);
			Manufacturers = await Context.Manufacturer.ToDictionaryAsync(m => $"{m.ManufacturerName}, {m.ManufacturerInc}", m => (int?)m.ManufacturerId);
		}
	}

	public string Theme => Id == 0 ? "primary" : "warning";
	public string Mode => Id == 0 ? "Create" : "Edit";

	public async Task HandleValidSubmit()
	{
		if (Context != null)
		{
			if (Mode == "Create")
			{
				Context.Add(ProductData);
			}
			await Context.SaveChangesAsync();
			NavManager?.NavigateTo("/forms");
		}
	}
}

위 예제에서는 새로운 개체의 생성과 기존에 기체를 수정하는 것을 명확하게 구분하기 위한 Bootstrap CSS theme와 함께 새로운 URL에 지원을 추가하였습니다. 또한 속성 수준의 validation message만을 사용하기 위해 validation summary를 삭제하였으며 Entity Framework Core를 통한 data의 저장기능을 추가하였습니다. Controller 혹은 Razor Page를 사용해 생성된 form application과는 다르게 Blazor에서는 초기 database query에서 Entity Framework Core가 생성한 개체를 대상으로 직접 작업할 수 있도록 하기 때문에 model binding을 다룰 필요가 없습니다. project를 실행하고 /forms로 URL을 요청하면 다음과 같이 Product의 개체에 대한 list를 볼 수 있을 것입니다.

이 상태에서 Create, Details, Edit, Delete와 같은 button을 click 하면 database의 data에 대한 작업을 수행할 수 있습니다.

5. Blazor Form기능의 확장

 

Blazor form기능은 효과적이지만 새로운 기술에서 항상 발견되는 미숙한 점을 가지고 있기도 합니다. 물론 기능은 release 되면서 다듬어질 수 있겠지만 그 사이에 Blazor를 사용하면 form이 작동하는 방식을 쉽게 향상할 수 있습니다. EditForm component에서는 form validation으로의 접근을 제공하며 아래 표에서 설명하고 있는 event, 속성, method를 통한 사용자정의 form component를 쉽게 생성할 수 있는 단계적 EditContext개체를 정의하고 있습니다.

OnFieldChanged 해당 event는 form field의 값이 변경되면 trigger됩니다.
OnValidationRequested 해당 event는 유효성검증이 필요하며 사용자 validation처리를 생성하기 위해 사용될때 trigger됩니다.
OnValidationStateChanged 해당 event는 전반적인 form의 validation상태가 바뀔때 trigger됩니다.
Model 해당 속성은 EditForm component의 Model속성으로 전달된 값을 반환합니다.
Field(name) 해당 method는 단일 field를 설명하는 FieldIdentifier개체를 가져오기 위해 사용됩니다.
IsModified() 해당 method는 모든 form field가 수정되는 경우 true를 반환합니다.
IsModified(field) 해당 method는 FieldIdentifier인수를 통해 지정된 field가 수정되는 경우 true를 반환합니다.
GetValidationMessages(field) 해당 method는 Field method로 부터 가져온 FieldIdentifer개체를 사용하여 단일 field에 대한 validation error message를 포함하는 결과를 반환합니다.
MarkAsUnmodified() 해당 method는 form을 수정되지 않는 것으로 표시합니다.
MarkAsUnmodified(field) 해당 method는 Field method로 부터 가져온 FieldIdentifer개체를 사용하여 특정한 field를 수정되지 않는 것으로 표시합니다.
NotifyValidationStateChanged() 해당 method는 validation에 대한 상태의 변화를 나타내는데 사용됩니다.
NotifyFieldChanged(field) 해당 method는 Field method로 부터 가져온 FieldIdentifer개체를 사용 지정한 field가 변경되었을때를 나타내기 위해 사용됩니다.
Validate() 해당 method는 form에서 validation을 수행하여 모든 form field가 validation을 통과하면 true를 그렇지 않으면 false를 반환합니다.

(1) 사용자정의 validation 제약조건 생성

 

만약 내장된 validation attribute가 사용하기에 적합하지 않다면 사용자 정의 validation 제약조건을 적용하는 component를 생성할 수 있습니다. 이러한 유형의 component는 자체 content를 render 하는 것이 아니기에 더 쉽게 class로 정의될 수 있습니다. Blazor/Forms folder에 DeptStateValidator.cs file을 아래와 같이 추가합니다.

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using MyBlazorApp.Models;
using System;

namespace MyBlazorApp.Blazor.Forms
{
	public class DeptStateValidator : OwningComponentBase<BlazorTDBContext>
	{
		public BlazorTDBContext Context => Service;

		[Parameter]
		public int CategoryId { get; set; }

		[Parameter]
		public string? ManufacturerInc { get; set; }

		[CascadingParameter]
		public EditContext? CurrentEditContext { get; set; }

		private string? CategoryName { get; set; }

		private IDictionary<int, string>? ManufacturerIncs { get; set; }

		protected override void OnInitialized()
		{
			if (CurrentEditContext != null) {
				ValidationMessageStore store = new ValidationMessageStore(CurrentEditContext);
				CurrentEditContext.OnFieldChanged += (sender, args) => {
					string name = args.FieldIdentifier.FieldName;

					if (name == "ProductCategoryId" || name == "ProductManufacturerId")
						Validate(CurrentEditContext.Model as Product, store);
				};
			}
		}

		protected override void OnParametersSet()
		{
			CategoryName = Context.Category.Find(CategoryId)?.CategoryName;
			ManufacturerIncs = Context.Manufacturer.ToDictionary(l => l.ManufacturerId, l => l.ManufacturerInc);
		}
		 
		private void Validate(Product? model, ValidationMessageStore store)
		{
			if (model?.ProductCategoryId == CategoryId && ManufacturerIncs != null && CurrentEditContext != null && (!ManufacturerIncs.ContainsKey(model.ProductManufacturerId ?? 0) || ManufacturerIncs[model.ProductManufacturerId ?? 0] != ManufacturerInc))
				store.Add(CurrentEditContext.Field("ProductManufacturerId"), $"{CategoryName} must be in: {ManufacturerInc}");
			else
				store.Clear();

			CurrentEditContext?.NotifyValidationStateChanged();
		}
	}
}

위 component는 예를 들어 Memory Category일 때만 'SAM'이라는 ManufacturerInc가 정확한 option일 수 있도록 하며 그 외 다른 모든 Manufacturerinc는 validation error를 생성하는 것처럼 Category에서 선언될 수 있는 ManufacturerInc를 강제하고 있습니다.

 

component는 또한 기반 class로 OwningComponentBase<T>를 사용해 수신하는 자체 scope 된 BlazorTDBContext개체를 가지고 있습니다. 상위 component에서는 CategoryId와 ManufacturerInc속성의 값을 제공하여 이 값이 validation 규칙에 사용되도록 합니다. 단계적 EditContext속성은 EditForm component로부터 수신되며 이전 표에서의 기능에 대한 접근을 제공합니다.

 

component가 초기화될 때 새로운 ValidationMessageStore가 생성되며 해당 개체는 validation error message를 등록하는 데 사용되어 아래와 같이 생성자 인수로 EditContext개체를 제공하게 됩니다.

ValidationMessageStore store = new ValidationMessageStore(CurrentEditContext);

Blazor는 store에 추가된 message를 처리하며 사용자 정의 validation component는 validation method에서 처리될 message가 어떤 것인지만 결정하면 됩니다. 해당 method는 ProductCategoryId와 ProductManufacturerId속성을 통해 이들의 결합이 허용되는지를 확인하고 문제가 있다면 아래와 같이 새로운 validation message를 store에 추가합니다.

store.Add(CurrentEditContext.Field("ProductManufacturerId"), $"{CategoryName} must be in: {ManufacturerInc}");

Add method의 인수는 error와 관련 field 및 validation message를 식별하기 위한 FieldIdentifier입니다. validation error가 없다면 message store의 Clear method가 호출되고 이로서 component에서 생성된 모든 message가 더 이상 표시되지 않도록 합니다.

 

Validation method는 OnFieldChanged event에 대한 handler에 의해 호출되며 사용자가 값에 대한 변경을 시도할 때마다 component가 응답할 수 있도록 합니다.

CurrentEditContext.OnFieldChanged += (sender, args) => {
	string name = args.FieldIdentifier.FieldName;

	if (name == "ProductCategoryId" || name == "ProductManufacturerId")
		Validate(CurrentEditContext.Model as Product, store);
};

handler는 FieldChangeEventArgs개체를 수신하는데 해당 개체는 어떤 field가 변경되었는지를 나타내는 FieldIdentifer속성을 정의하고 있습니다. 아래 예제는 Blazor/Forms folder의 Editor.razor file을 변경한 것으로 Editor component에 새로운 validation을 적용하고 있습니다.

<DataAnnotationsValidator />
<DeptStateValidator CategoryId="2" ManufacturerInc="SAM" />

여기서 CategoryId와 ManufacturerInc속성을 통해 CategoryId가 2(Memory)인 경우에만 ManufacturerInc가 SAM으로만 선택될 수 있도록 제한을 시도하고 있습니다. project를 실행하고 /forms/edit/4로 URL을 요청한 뒤 Category ID를 'Memory'로 선택한 뒤 Manufacturer Inc가 'SAM'이 아닌 다른 것을 선택하면 아래와 같이 validation error를 볼 수 있습니다.

해당 error는  사용자가 Manufacturer Inc가 'SAM'인 것을 선택하거나 Cateogry를 다른 것으로 바꾸기 전까지는 계속 남아있을 것입니다.

 

(2) 유효전용 Submit Button Component 생성

 

이제 마지막으로 data가 유효할 때만 사용가능한 button으로 form의 submit button을 render 하는 component를 생성할 것입니다. Blaor/Forms folder에 VaildButton.razor이름의 Razor component를 아래와 같이 추가합니다.

<button class="@ButtonClass" @attributes="Attributes" disabled="@Disabled">
	@ChildContent
</button>

@code {
	[Parameter]
	public RenderFragment? ChildContent { get; set; }

	[Parameter]
	public string BtnTheme { get; set; } = "primary";

	[Parameter]
	public string DisabledClass { get; set; } = "btn-outline-dark disabled";

	[Parameter(CaptureUnmatchedValues = true)]
	public IDictionary<string, object>? Attributes { get; set; }

	[CascadingParameter]
	public EditContext? CurrentEditContext { get; set; }

	public bool Disabled { get; set; }

	public string ButtonClass => Disabled ? $"btn btn-{BtnTheme} {DisabledClass} mt-2" : $"btn btn-{BtnTheme} mt-2";

	protected override void OnInitialized()
	{
		SetButtonState();

		if (CurrentEditContext != null)
		{
			CurrentEditContext.OnValidationStateChanged +=
			(sender, args) => SetButtonState();
			CurrentEditContext.Validate();
		}
	}

	public void SetButtonState()
	{
		if (CurrentEditContext != null)
			Disabled = CurrentEditContext.GetValidationMessages().Any();

	}
}

위 예제는 OnValidationStateChanged method에 응답하는 component로서 form의 validation상태가 바뀔 때 trigger 됩니다. validation상태를 자세히 설명하는 EditContext는 없기 때문에 validation문제가 있는지를 확인하기 위한 가장 좋은 방법은 validation message가 있는지를 확인하는 것입니다. 만약 그렇다면 validation문제가 있는 것입니다. 반대로 validation message가 없다면 form은 유효한 것입니다. button의 상태가 정확히 표시되도록 하려면 Validation method를 호출하여 component가 초기화되지 마자 validation확인을 수행하도록 합니다.

 

아래 예제는 Blazor/Forms folder의 Editor.azor file을 변경한 것으로 Editor component에서 submit button을 위의 새로운 component로 교체한 것입니다.

<div class="text-center">
	<div class="text-center">
		<ValidButton type="submit" BtnTheme="@Theme">Save</ValidButton>
		<NavLink class="btn btn-secondary mt-2" href="/forms">Back</NavLink>
	</div>
</div>

project를 실행하고 /forms/create URL을 요청하면 각 form요소에 대한 validation message가 표시되고 아래와 같이 Save button이 비활성화되어 있음을 확인할 수 있습니다. button이 다시 활성화되려면 각 validation문제가 모두 해결되어야 합니다.

728x90