构建 Blazor 编辑器框架





5.00/5 (5投票s)
如何为 Blazor 表单构建编辑器框架
引言
这是两篇关于如何在 Blazor 中实现编辑表单的文章中的第二篇。
第一篇文章探讨了如何控制用户在表单被修改后可以做什么;本质上,是如何防止用户意外退出。本文介绍如何构建一个框架,该框架能够检测数据集何时被修改或无效,并“锁定”应用程序。
许多人可能认为 Blazor 已经具备了足够的编辑数据功能。为什么要重新发明轮子是一个有效的问题?如果您坚信这一点,请不要再往下看了:这篇文章不适合您。如果不是,请继续阅读并自己做出决定。
一些近期的背景。C# 9 引入了 Record
类型,创建了一种不可变的引用类型。{get; init;}
属性允许我们创建不可变的属性。这些是最近的语言更改:微软在重新思考?
我坚信要保持从数据库读取的记录和记录集的完整性。您在参考记录或记录集中看到的内容就是数据库中的内容。如果您想编辑某项内容,有一个过程,不要随意更改原始内容。制作一份副本,更改副本,将副本提交到数据库,然后从数据库刷新您的参考数据。
我使用的编辑框架(本文中所述)实现了这些原则。
概述
这个简短的讨论和项目使用开箱即用的 Blazor WeatherForecast
record 作为我们的示例。
DbWeatherForecast
代表从数据库读取的记录。它被声明为 class
,而不是 record
:只有表示数据库字段的属性是不可变的。DbWeatherForecast
的可编辑版本保存在 RecordCollection
中。DbWeatherForecast
具有构建和从 RecordCollection
读取数据的 . 方法。RecordCollection
是一个 IEnumerable
对象,其中包含 RecordFieldValue
对象的列表。每个对象代表 DbWeatherForecast
中的一个字段/属性。RecordFieldValue
具有自己的不可变字段 Value
和 FieldName
,以及一个可以设置的 EditedValue
字段。IsDirty
是一个布尔属性,表示 RecordFieldValue
的编辑状态。RecordCollection
和 RecordFieldValue
类提供了对底层数据值的受控访问。
WeatherForecastEditContext
是 DbWeatherForecast
的 UI 编辑器对象,它公开了 DbWeatherForecast
的 RecordCollection
的可编辑属性。它与 EditContext
具有共生关系,跟踪 RecordCollection
的编辑状态并提供任何需要数据验证的属性的验证。
在项目中,WeatherForecastControllerService
是提供对 WeatherForecast
数据访问的业务对象。编辑器和查看器调用 GetForecastAsync(id)
来加载 WeatherForecastControllerService
中的当前 DbWeatherForecast
记录。RecordData
,即 DbWeatherForecast
记录的 RecordCollection
,由 GetForecastAsync(id)
填充。当 UI 初始化 WeatherForecastEditContext
的实例时,它会向其传递 WeatherForecastControllerService
的 RecordData
RecordCollection
。此时需要注意的是,当加载新的 DbWeatherForecast
时,RecordData
不会被替换,而是会被清空然后重新填充:传递给 WeatherForecastEditContext
的引用始终有效。
示例代码
一如既往,有一个 GitHub 仓库 CEC.Blazor.Editor。CEC.Blazor.ModalEditor
是本文的项目。
基础设施类
一如既往,我们需要一些支持类来完成主要任务。
RecordFieldValue
如前所述,RecordFieldValue
存储有关记录集字段的信息。
注意
- 派生自实际记录的属性是
{get; init;}
。它们只能在创建RecordFieldValue
实例时设置。 FieldName
是字段的属性名称。我们定义它以确保在整个应用程序中使用相同的string
值。Value
是字段的数据库值。ReadOnly
不言自明。它用于标记派生/计算字段。DisplayName
是显示字段名称时使用的string
。EditedValue
是我们编辑上下文中字段的当前值。getter 确保在第一次get
时,如果尚未set
,则将其设置为Value
。IsDirty
对Value
和EditedValue
执行默认的相等性检查,以确定Field
是否已修改。Reset
将EditedValue
设置回Value
。- 两个
Clone
方法创建RecordEditValue
的新副本。
using System;
namespace CEC.Blazor.Editor
{
public class RecordFieldValue
{
public string FieldName { get; init; }
public object Value { get; init; }
public bool ReadOnly { get; init; }
public string DisplayName { get; set; }
public object EditedValue
{
get
{
if (this._EditedValue is null && this.Value != null)
this._EditedValue = this.Value;
return this._EditedValue;
}
set => this._EditedValue = value;
}
private object _EditedValue { get; set; }
public bool IsDirty
{
get
{
if (Value != null && EditedValue != null) return !Value.Equals(EditedValue);
if (Value is null && EditedValue is null) return false;
return true;
}
}
public RecordFieldValue() { }
public RecordFieldValue(string field, object value)
{
this.FieldName = field;
this.Value = value;
this.EditedValue = value;
this.GUID = Guid.NewGuid();
}
public void Reset()
=> this.EditedValue = this.Value;
public RecordFieldValue Clone()
{
return new RecordFieldValue()
{
DisplayName = this.DisplayName,
FieldName = this.FieldName,
Value = this.Value,
ReadOnly = this.ReadOnly
};
}
public RecordFieldValue Clone(object value)
{
return new RecordFieldValue()
{
DisplayName = this.DisplayName,
FieldName = this.FieldName,
Value = value,
ReadOnly = this.ReadOnly
};
}
}
}
RecordCollection
RecordCollection
是 RecordFieldValue
对象的托管 IEnumerable
集合。
注意
- 有许多 getter、setter 等用于访问和更新各个
RecordFieldValue
对象。 IsDirty
检查集合中是否有任何已修改的项目。FieldValueChanged
是在每次设置单个RecordFieldValue
时触发的事件。您可以看到它在调用SetField
时被调用。
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
namespace CEC.Blazor.Editor
{
public class RecordCollection :IEnumerable<RecordFieldValue>
{
private List<RecordFieldValue> _items = new List<RecordFieldValue>();
public int Count => _items.Count;
public Action<bool> FieldValueChanged;
public bool IsDirty => _items.Any(item => item.IsDirty);
public IEnumerator<RecordFieldValue> GetEnumerator()
{
foreach (var item in _items)
yield return item;
}
IEnumerator IEnumerable.GetEnumerator()
=> this.GetEnumerator();
public void ResetValues()
=> _items.ForEach(item => item.Reset());
public void Clear()
=> _items.Clear();
// ....... lots of getters, setters, deleters, adders. A few examples show.
public T Get<T>(string FieldName)
{
var x = _items.FirstOrDefault(item => item.FieldName.Equals
(FieldName, StringComparison.CurrentCultureIgnoreCase));
if (x != null && x.Value is T t) return t;
return default;
}
public T GetEditValue<T>(string FieldName)
{
var x = _items.FirstOrDefault(item => item.FieldName.Equals
(FieldName, StringComparison.CurrentCultureIgnoreCase));
if (x != null && x.EditedValue is T t) return t;
return default;
}
public bool SetField(string FieldName, object value)
{
var x = _items.FirstOrDefault(item => item.FieldName.Equals
(FieldName, StringComparison.CurrentCultureIgnoreCase));
if (x != null && x != default)
{
x.EditedValue = value;
this.FieldValueChanged?.Invoke(this.IsDirty);
}
else _items.Add(new RecordFieldValue(FieldName, value));
return true;
}
}
RecordEditContext
RecordEditContext
是记录编辑上下文的基类。它包含样板代码。我们将在 WeatherForecastEditContext
中详细介绍它。需要注意的关键点:
- 它的初始化器需要一个
RecordCollection
对象。在应用程序中,这是ControllerService
的RecordCollection
,称为与当前记录关联的RecordData
。每当记录更改时,它都会加载。 - 它保存对有效
EditContext
的引用,并期望收到更改通知。 - 它处理
EditContext
的验证,并连接到EditContext.OnValidationRequested
。 - 它保存一个
ValidationActions
列表,该列表在触发验证时运行。
using Microsoft.AspNetCore.Components.Forms;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
namespace CEC.Blazor.Editor
{
public abstract class RecordEditContext : IRecordEditContext
{
public EditContext EditContext { get; private set; }
public bool IsValid => !Trip;
public bool IsDirty => this.RecordValues?.IsDirty ?? false;
public bool IsClean => !this.IsDirty;
public bool IsLoaded => this.EditContext != null && this.RecordValues != null;
protected RecordCollection RecordValues { get; private set; } = new RecordCollection();
protected bool Trip = false;
protected List<Func<bool>> ValidationActions { get; } = new List<Func<bool>>();
protected virtual void LoadValidationActions() { }
protected ValidationMessageStore ValidationMessageStore;
private bool Validating;
public RecordEditContext(RecordCollection collection)
{
Debug.Assert(collection != null);
if (collection is null)
throw new InvalidOperationException($"{nameof(RecordEditContext)}
requires a valid {nameof(RecordCollection)} object");
else
{
this.RecordValues = collection;
this.LoadValidationActions();
}
}
public bool Validate()
{
// using Validating to stop being called multiple times
if (ValidationMessageStore != null && !this.Validating)
{
this.Validating = true;
// clear the message store and trip wire and check we have Validators to run
this.ValidationMessageStore.Clear();
this.Trip = false;
foreach (var validator in this.ValidationActions)
{
// invoke the action - defined as a func<bool> and
// trip if validation failed (false)
if (!validator.Invoke()) this.Trip = true;
}
this.EditContext.NotifyValidationStateChanged();
this.Validating = false;
}
return IsValid;
}
public Task NotifyEditContextChangedAsync(EditContext context)
{
var oldcontext = this.EditContext;
if (context is null)
throw new InvalidOperationException($"{nameof(RecordEditContext)} -
NotifyEditContextChangedAsync requires a valid {nameof(EditContext)} object");
// if we already have an edit context,
// we will have registered with OnValidationRequested,
// so we need to drop it before losing our reference to the editcontext object.
if (this.EditContext != null)
{
EditContext.OnValidationRequested -= ValidationRequested;
}
// assign the Edit Context internally
this.EditContext = context;
if (this.IsLoaded)
{
// Get the Validation Message Store from the EditContext
this.ValidationMessageStore = new ValidationMessageStore(EditContext);
// Wire up to the Editcontext to service Validation Requests
this.EditContext.OnValidationRequested += this.ValidationRequested;
}
// Call a validation on the current data set
this.Validate();
return Task.CompletedTask;
}
private void ValidationRequested(object sender, ValidationRequestedEventArgs args)
{
this.Validate();
}
}
}
DbWeatherForecast
新的天气预报记录。虽然我们只即时创建这些记录,但普通应用程序会从数据库获取它们。
注意
- 类中有一个
static
声明的RecordFieldValue
,用于每个数据库属性/字段。在大型应用程序中,这些应该在中央的DataDictionary
中声明。 - “
Database
”属性全部声明为{ get; init; }
:它们是不可变的。 AsRecordCollection
从记录构建一个RecordCollection
对象。FromRecordCollection
是static
的,它使用编辑后的值从提供的RecordCollection
构建一个新记录。
using System;
namespace CEC.Blazor.Editor
{
public class DbWeatherForecast
{
public static RecordFieldValue __ID = new RecordFieldValue()
{ FieldName = "ID", DisplayName = "ID" };
public static RecordFieldValue __Date = new RecordFieldValue()
{ FieldName = "Date", DisplayName = "Forecast Date" };
public static RecordFieldValue __TemperatureC = new RecordFieldValue()
{ FieldName = "TemperatureC", DisplayName = "Temperature C" };
public static RecordFieldValue __TemperatureF = new RecordFieldValue()
{ FieldName = "TemperatureF", DisplayName = "Temperature F", ReadOnly = true };
public static RecordFieldValue __Summary = new RecordFieldValue()
{ FieldName = "Summary", DisplayName = "Summary" };
public Guid ID { get; init; } = Guid.Empty;
public DateTime Date { get; init; } = DateTime.Now;
public int TemperatureC { get; init; } = 25;
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string Summary { get; init; }
public RecordCollection AsRecordCollection
{
get
{
var coll = new RecordCollection();
{
coll.Add(__ID.Clone(this.ID));
coll.Add(__Date.Clone(this.Date));
coll.Add(__TemperatureC.Clone(this.TemperatureC));
coll.Add(__TemperatureF.Clone(this.TemperatureF));
coll.Add(__Summary.Clone(this.Summary));
}
return coll;
}
}
public static DbWeatherForecast FromRecordCollection(RecordCollection coll)
=> new DbWeatherForecast()
{
ID = coll.GetEditValue<Guid>(__ID.FieldName),
Date = coll.GetEditValue<DateTime>(__Date.FieldName),
TemperatureC = coll.GetEditValue<int>(__TemperatureC.FieldName),
Summary = coll.GetEditValue<string>(__Summary.FieldName)
};
}
}
数据服务
我已将数据访问分成数据服务和控制器服务:这更现实。我们可能在创建一个虚拟数据集,但我正在模仿常规做法。在生产系统中,这将运行在接口和样板化的基类实现上。
WeatherForecastDataService
数据服务
- 在启动时构建虚拟数据集。
- 在该数据集上提供 CRUD 数据操作。
- 我们使用 Guid 作为 Id。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace CEC.Blazor.Editor
{
public class WeatherForecastDataService
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool",
"Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private List<DbWeatherForecast> Forecasts
{ get; set; } = new List<DbWeatherForecast>();
public WeatherForecastDataService()
=> PopulateForecasts();
public void PopulateForecasts()
{
var rng = new Random();
for (int x = 0; x < 5; x++)
{
Forecasts.Add(new DbWeatherForecast
{
ID = Guid.NewGuid(),
Date = DateTime.Now.AddDays((double)x),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
});
}
}
public Task<List<DbWeatherForecast>> GetForecastsAsync()
=> Task.FromResult(this.Forecasts);
public Task<DbWeatherForecast> GetForecastAsync(Guid id)
=> Task.FromResult(this.Forecasts.FirstOrDefault(item => item.ID.Equals(id)));
public Task<Guid> UpdateForecastAsync(DbWeatherForecast record)
{
var rec = this.Forecasts.FirstOrDefault(item => item.ID.Equals(record.ID));
if (rec != default) this.Forecasts.Remove(rec);
this.Forecasts.Add(record);
return Task.FromResult(record.ID);
}
public Task<Guid> AddForecastAsync(DbWeatherForecast record)
{
var id = Guid.NewGuid();
if (record.ID.Equals(Guid.Empty))
{
var recdata = record.AsRecordCollection;
recdata.SetField(DbWeatherForecast.__ID.FieldName, id);
record = DbWeatherForecast.FromRecordCollection(recdata);
}
else
{
var rec = this.Forecasts.FirstOrDefault(item => item.ID.Equals(record.ID));
if (rec != default) return Task.FromResult(Guid.Empty);
}
this.Forecasts.Add(record);
return Task.FromResult(id);
}
}
}
Controller Data
控制器服务是数据和 UI 之间的接口,提供对数据的更高层次的业务逻辑接口。大多数属性和方法不言自明。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace CEC.Blazor.Editor
{
public class WeatherForecastControllerService
{
public WeatherForecastDataService DataService { get; set; }
public event EventHandler RecordChanged;
public event EventHandler ListChanged;
public RecordCollection RecordData { get; } = new RecordCollection();
public List<DbWeatherForecast> Forecasts {
get => _Forecasts;
private set
{
_Forecasts = value;
ListChanged?.Invoke(value, EventArgs.Empty);
}
}
private List<DbWeatherForecast> _Forecasts;
public DbWeatherForecast Forecast
{
get => _Forecast;
private set
{
_Forecast = value;
RecordData.AddRange(_Forecast.AsRecordCollection, true);
RecordChanged?.Invoke(_Forecast, EventArgs.Empty);
}
}
private DbWeatherForecast _Forecast;
public WeatherForecastControllerService
(WeatherForecastDataService weatherForecastDataService )
=> this.DataService = weatherForecastDataService;
public async Task GetForecastsAsync()
=> this.Forecasts = await DataService.GetForecastsAsync();
public async Task GetForecastAsync(Guid id)
{
this.Forecast = await DataService.GetForecastAsync(id);
this.RecordChanged?.Invoke(RecordChanged, EventArgs.Empty);
}
public async Task<bool> SaveForecastAsync()
{
Guid id = Guid.Empty;
var record = DbWeatherForecast.FromRecordCollection(this.RecordData);
if (this.Forecast.ID.Equals(Guid.Empty))
id = await this.DataService.AddForecastAsync(record);
else
id = await this.DataService.UpdateForecastAsync(record);
if (!id.Equals(Guid.Empty))
await GetForecastAsync(id);
return !id.Equals(Guid.Empty);
}
}
}
构建 UI
继续到 UI 并稍微离题。
UI 组件
我对许多 UI 代码的一个抱怨是 HTML 重复。开发人员在 Razor 标记中做的事情,他们绝不会梦想在 C# 代码中这样做。编辑器/显示/列表表单是很好的例子。我已经将大部分重复的 HTML 标记移到了 UI 组件 中:在我的应用程序中,HTML 标记不属于高级组件。格式问题,如间距不足。在一个地方修复它,所有地方都修复了!
让我们看几个例子。所有 UI 组件都在 UIComponents 目录中。
UIFormRow
并非高深莫测。ChildContent
是在开始和结束语句之间输入的默认定义。
@namespace CEC.Blazor.Editor
<div class="row form-group">
@this.ChildContent
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
}
有了这个,您现在可以将每一行声明为
<UIFormRow>
....(ChildContent)
</UIFormRow>
UIButton
同样简单,但它使高级声明最小化。
@if (this.Show)
{
<button class="btn mr-1 @this.CssColor" @onclick="ButtonClick">
@this.ChildContent
</button>
}
@code {
[Parameter] public bool Show { get; set; } = true;
[Parameter] public EventCallback<MouseEventArgs> ClickEvent { get; set; }
[Parameter] public string CssColor { get; set; } = "btn-primary";
[Parameter] public RenderFragment ChildContent { get; set; }
protected void ButtonClick(MouseEventArgs e) => this.ClickEvent.InvokeAsync(e);
}
<UIButton CssColor="btn-success" Show="this.CanSave"
ClickEvent="this.Save">@this.SaveButtonText</UIButton>
ModalEditForm
ModalEditForm
替换了 EditForm
。它
- 具有三个
RenderFragments
。 LoadingContent
仅在表单加载时显示。EditorContent
在加载完成后显示。它级联EditContext
。ButtonContent
始终显示在控件底部。Loaded
控制渲染的内容。- 我们使用
BuildRenderTree
构建控件。
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Rendering;
namespace CEC.Blazor.ModalEditor
{
public class ModalEditForm : ComponentBase
{
[Parameter] public RenderFragment EditorContent { get; set; }
[Parameter] public RenderFragment ButtonContent { get; set; }
[Parameter] public RenderFragment LoadingContent { get; set; }
[Parameter] public bool Loaded { get; set; }
[Parameter] public EditContext EditContext {get; set;}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (this.Loaded)
{
builder.OpenRegion(EditContext.GetHashCode());
builder.OpenComponent<CascadingValue<EditContext>>(1);
builder.AddAttribute(2, "IsFixed", true);
builder.AddAttribute(3, "Value", EditContext);
builder.AddAttribute(4, "ChildContent", EditorContent);
builder.CloseComponent();
builder.CloseRegion();
}
else
builder.AddContent(10, LoadingContent );
builder.AddContent(20, ButtonContent);
}
}
}
WeatherDataModal
继续进行实际的 UI 工作。
这取代了 FetchData
。它很相似。Edit
和 View
按钮现在传递记录的 ID
。我没有用 UI 控件替换 HTML,所以您可以看到变化多么小。
@page "/weatherdatamodal"
@using CEC.Blazor.Editor.Data
@namespace CEC.Blazor.Editor.Pages
<ModalDialog @ref="this.Modal"></ModalDialog>
<h1>Weather forecast</h1>
<p>This component demonstrates fetching data from a service.</p>
@if (this.ForecastService.Forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in this.ForecastService.Forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
<td class="text-right">
<button class="btn btn-sm btn-secondary"
@onclick="() => ShowViewDialog(forecast.ID)">View</button>
<button class="btn btn-sm btn-primary"
@onclick="() => ShowEditDialog(forecast.ID)">Edit</button>
</td>
</tr>
}
</tbody>
</table>
}
在代码中
- 我们现在使用新的
WeatherForecastControllerService
,而 Razor 标记使用服务Forecasts
列表。 - 我们在
WeatherForecastControllerService
的表单OnInitializedAsync()
中加载Forecasts
列表。 - 两个按钮处理程序创建一个
ModalOptions
对象并将ID
添加到传递给编辑器和查看器表单。
using Microsoft.AspNetCore.Components;
using System;
using System.Threading.Tasks;
namespace CEC.Blazor.Editor.Pages
{
public partial class WeatherDataModal : ComponentBase
{
[Inject] WeatherForecastControllerService ForecastService { get; set; }
private ModalDialog Modal { get; set; }
protected async override Task OnInitializedAsync()
{
await ForecastService.GetForecastsAsync();
}
private async void ShowViewDialog(Guid id)
{
var options = new ModalOptions();
{
options.Set(ModalOptions.__Width, "80%");
options.Set(ModalOptions.__ID, id);
}
await this.Modal.ShowAsync<WeatherViewer>(options);
}
private async void ShowEditDialog(Guid id)
{
var options = new ModalOptions();
{
options.Set(ModalOptions.__Width, "80%");
options.Set(ModalOptions.__ID, id);
}
await this.Modal.ShowAsync<WeatherForecastEditor>(options);
}
}
}
WeatherForecastEditor
编辑器是设置所有编辑组件的容器,然后根据底层 EditContext
和 RecordEditorContext
的变化更新 UI 中的按钮。一组布尔属性控制 UI 和按钮状态。
初始化时,它
- 从
ModalOptions
获取记录 ID - 加载控制器
DbWeatherForecast
- 这会加载服务中的RecordData
,即RecordCollection
对象 - 创建一个新的
RecordEditorContext
,传入RecordCollection
- 创建一个
EditContext
,并将RecordEditorContext
作为模态框 - 通知
RecordEditorContext
EditContext
已更改 - 将
EditContext.OnFieldChanged
事件连接到本地的OnFieldChanged
事件处理程序
需要注意的关键点:
- 编辑器将本地属性连接到级联的
ModalDialog
,以便它可以锁定和解锁表单并退出。 - 包含保存和各种退出方法。
OnFieldChanged
处理 UI 按钮、表单锁定和渲染。它不与数据交互 - 所有这些都由RecordEditorContext
完成。
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using System;
using System.Threading.Tasks;
namespace CEC.Blazor.Editor
{
public partial class WeatherForecastEditor : ComponentBase
{
public EditContext EditContext => _EditContext;
private EditContext _EditContext = null;
protected WeatherForecastEditContext RecordEditorContext { get; set; }
[Inject] protected WeatherForecastControllerService ControllerService { get; set; }
[CascadingParameter] private IModalDialog Modal { get; set; }
private bool IsModal => this.Modal != null;
private bool HasServices => this.IsModal && this.ControllerService != null;
private bool IsDirtyExit;
private bool IsDirty => RecordEditorContext.IsDirty;
private bool IsValid => RecordEditorContext.IsValid;
private bool IsLoaded => RecordEditorContext?.IsLoaded ?? false;
private bool CanSave => this.IsDirty && this.IsValid;
private bool CanExit => !this.IsDirtyExit;
private string SaveButtonText =>
this.ControllerService.Forecast.ID.Equals(Guid.Empty) ? "Save" : "Update";
protected async override Task OnInitializedAsync()
{
//await Task.Yield();
if (this.HasServices && Modal.Options.TryGet<Guid>
(ModalOptions.__ID, out Guid modalid))
{
await this.ControllerService.GetForecastAsync(modalid);
this.RecordEditorContext = new WeatherForecastEditContext
(this.ControllerService.RecordData);
this._EditContext = new EditContext(RecordEditorContext);
await this.RecordEditorContext.NotifyEditContextChangedAsync
(this.EditContext);
this.EditContext.OnFieldChanged += OnFieldChanged;
}
await base.OnInitializedAsync();
}
protected void OnFieldChanged(object sender, EventArgs e)
=> this.SetLock();
private void SetLock()
{
this.IsDirtyExit = false;
if (this.RecordEditorContext.IsDirty)
this.Modal.Lock(true);
else
this.Modal.Lock(false);
InvokeAsync(StateHasChanged);
}
protected async Task<bool> Save()
{
var ok = false;
// Validate the EditContext
if (this.RecordEditorContext.EditContext.Validate())
{
// Save the Record
ok = await this.ControllerService.SaveForecastAsync();
if (ok)
{
// Set the EditContext State
this.RecordEditorContext.EditContext.MarkAsUnmodified();
// Set the View Lock i.e. unlock it
this.SetLock();
}
}
return ok;
}
protected void Exit()
{
if (RecordEditorContext.IsDirty)
{
this.IsDirtyExit = true;
this.InvokeAsync(StateHasChanged);
}
else
this.Modal.Close(ModalResult.OK());
}
protected void DirtyExit()
{
this.Modal.Lock(false);
this.Modal.Close(ModalResult.OK());
}
protected void CancelExit()
=> SetLock();
}
}
标记代码是相当标准的编辑器内容。
- 您可以看到
UIComponents
的使用,以标准化 HTML。 EditForm
被ModalEditForm
替换。它确保内容在加载前不会渲染,并级联EditContext
。- 按钮使用各种布尔属性来控制它们的显示状态。
@namespace CEC.Blazor.ModalEditor
<UIContainer>
<UIFormRow>
<UIColumn>
<h2>Weather Forecast Editor</h2>
</UIColumn>
</UIFormRow>
</UIContainer>
<ModalEditForm EditContext="this.EditContext" Loaded="this.IsLoaded">
<LoadingContent>
... loading
</LoadingContent>
<EditorContent>
<UIContainer>
<UIFormRow>
<UILabelColumn>
Date
</UILabelColumn>
<UIInputColumn Cols="3">
<InputDate class="form-control"
@bind-Value="this.RecordEditorContext.Date"></InputDate>
</UIInputColumn>
<UIColumn Cols="3"></UIColumn>
<UIValidationColumn>
<ValidationMessage For=@(() => this.RecordEditorContext.Date) />
</UIValidationColumn>
</UIFormRow>
<UIFormRow>
<UILabelColumn>
Temperature °C
</UILabelColumn>
<UIInputColumn Cols="2">
<InputNumber class="form-control"
@bind-Value="this.RecordEditorContext.TemperatureC"></InputNumber>
</UIInputColumn>
<UIColumn Cols="4"></UIColumn>
<UIValidationColumn>
<ValidationMessage For=@(() => this.RecordEditorContext.TemperatureC) />
</UIValidationColumn>
</UIFormRow>
<UIFormRow>
<UILabelColumn>
Summary
</UILabelColumn>
<UIInputColumn>
<InputText class="form-control"
@bind-Value="this.RecordEditorContext.Summary"></InputText>
</UIInputColumn>
<UIValidationColumn>
<ValidationMessage For=@(() => this.RecordEditorContext.Summary) />
</UIValidationColumn>
</UIFormRow>
</UIContainer>
</EditorContent>
<ButtonContent>
<UIContainer>
<UIFormRow>
<UIButtonColumn>
<UIButton CssColor="btn-success"
Show="this.CanSave"
ClickEvent="this.Save">@this.SaveButtonText</UIButton>
<UIButton CssColor="btn-danger"
Show="this.IsDirtyExit"
ClickEvent="this.DirtyExit">Exit Without Saving</UIButton>
<UIButton CssColor="btn-warning"
Show="this.IsDirtyExit"
ClickEvent="this.CancelExit">Cancel Exit</UIButton>
<UIButton CssColor="btn-secondary"
Show="this.CanExit"
ClickEvent="this.Exit">Exit</UIButton>
</UIButtonColumn>
</UIFormRow>
</UIContainer>
</ButtonContent>
</ModalEditForm>
WeatherForecastEditorContext
注意
- 暴露
RecordValues
中底层字段的属性,这些属性一直引用到 ControllerService 中的RecordCollection
对象。 - 属性 setter 设置
RecordFieldValue
上的EditedValue
。 - 属性 setter 调用
Validate
并触发整个表单编辑组件中的验证过程 - 将任何控件变成红色并显示任何验证消息。 - 为需要验证的属性定义的
Validators
。 - 通过
LoadValidationActions
加载的验证器。
using System;
namespace CEC.Blazor.Editor
{
public class WeatherForecastEditContext : RecordEditContext, IRecordEditContext
{
public DateTime Date
{
get => this.RecordValues.GetEditValue<DateTime>
(DbWeatherForecast.__Date.FieldName);
set
{
this.RecordValues.SetField(DbWeatherForecast.__Date.FieldName, value);
this.Validate();
}
}
public string Summary
{
get => this.RecordValues.GetEditValue<string>
(DbWeatherForecast.__Summary.FieldName);
set
{
this.RecordValues.SetField(DbWeatherForecast.__Summary.FieldName, value);
this.Validate();
}
}
public int TemperatureC
{
get => this.RecordValues.GetEditValue<int>
(DbWeatherForecast.__TemperatureC.FieldName);
set
{
this.RecordValues.SetField
(DbWeatherForecast.__TemperatureC.FieldName, value);
this.Validate();
}
}
public Guid WeatherForecastID
=> this.RecordValues.GetEditValue<Guid>(DbWeatherForecast.__ID.FieldName);
public WeatherForecastEditContext(RecordCollection collection) : base(collection) { }
protected override void LoadValidationActions()
{
this.ValidationActions.Add(ValidateSummary);
this.ValidationActions.Add(ValidateTemperatureC);
this.ValidationActions.Add(ValidateDate);
}
private bool ValidateSummary()
{
return this.Summary.Validation
(DbWeatherForecast.__Summary.FieldName, this, ValidationMessageStore)
.LongerThan(2,
"Your description needs to be a little longer! 3 letters minimum")
.Validate();
}
private bool ValidateDate()
{
return this.Date.Validation
(DbWeatherForecast.__Date.FieldName, this, ValidationMessageStore)
.NotDefault("You must select a date")
.LessThan(DateTime.Now.AddMonths(1), true,
"Date can only be up to 1 month ahead")
.Validate();
}
private bool ValidateTemperatureC()
{
return this.TemperatureC.Validation
(DbWeatherForecast.__TemperatureC.FieldName, this, ValidationMessageStore)
.LessThan(70, "The temperature must be less than 70C")
.GreaterThan(-60, "The temperature must be greater than -60C")
.Validate();
}
}
}
Validators
WeatherForecastEditorContext
使用自定义验证过程。它并非高深莫测,一旦您理解了原则,它就非常灵活。
跳到下一节,先看看实现,然后再回到 abstract
Validator
类。这样会更容易理解。
using Microsoft.AspNetCore.Components.Forms;
using System.Collections.Generic;
namespace CEC.Blazor.Editor
{
public abstract class Validator<T>
{
public bool IsValid => !Trip;
public bool Trip = false;
public List<string> Messages { get; } = new List<string>();
protected string FieldName { get; set; }
protected T Value { get; set; }
protected string DefaultMessage { get; set; } = "The value failed validation";
protected ValidationMessageStore ValidationMessageStore { get; set; }
protected object Model { get; set; }
public Validator(T value, string fieldName,
object model, ValidationMessageStore validationMessageStore, string message)
{
this.FieldName = fieldName;
this.Value = value;
this.Model = model;
this.ValidationMessageStore = validationMessageStore;
this.DefaultMessage = string.IsNullOrWhiteSpace(message) ?
this.DefaultMessage : message;
}
public virtual bool Validate(string message = null)
{
if (!this.IsValid)
{
message ??= this.DefaultMessage;
// Check if we've logged specific messages. If not add the default message
if (this.Messages.Count == 0) Messages.Add(message);
//set up a FieldIdentifier and add the message to the
//Edit Context ValidationMessageStore
var fi = new FieldIdentifier(this.Model, this.FieldName);
this.ValidationMessageStore.Add(fi, this.Messages);
}
return this.IsValid;
}
protected void LogMessage(string message)
{
if (!string.IsNullOrWhiteSpace(message)) Messages.Add(message);
}
}
}
StringValidator
这是一个用于 string
的 Validator
。
验证器工作原理的关键是 static
类。Validation
是 string
的扩展方法。当您在 string
上调用 Validation
时,它会创建一个 StringValidator
对象并返回它。现在您有了一个 StringValidator
,您可以在其上调用验证方法。每个验证方法都会返回对 validation
对象的引用。您可以将任意数量的验证方法链接在一起,并附带其特定的消息。您调用基类 Validate
方法来完成该过程。它将任何验证消息记录到 ValidationMessageStore
中,并返回 true
或 false
。ValidationMessageStore
与 EditContext
相关联。
using Microsoft.AspNetCore.Components.Forms;
using System.Text.RegularExpressions;
namespace CEC.Blazor.Editor
{
public static class StringValidatorExtensions
{
public static StringValidator Validation(this string value, string fieldName,
object model, ValidationMessageStore validationMessageStore, string message = null)
{
var validation = new StringValidator
(value, fieldName, model, validationMessageStore, message);
return validation;
}
}
public class StringValidator : Validator<string>
{
public StringValidator(string value, string fieldName, object model,
ValidationMessageStore validationMessageStore, string message) :
base(value, fieldName, model, validationMessageStore, message) { }
public StringValidator LongerThan(int test, string message = null)
{
if (string.IsNullOrEmpty(this.Value) || !(this.Value.Length > test))
{
Trip = true;
LogMessage(message);
}
return this;
}
public StringValidator ShorterThan(int test, string message = null)
{
if (string.IsNullOrEmpty(this.Value) || !(this.Value.Length < test))
{
Trip = true;
LogMessage(message);
}
return this;
}
public StringValidator Matches(string pattern, string message = null)
{
if (!string.IsNullOrWhiteSpace(this.Value))
{
var match = Regex.Match(this.Value, pattern);
if (match.Success && match.Value.Equals(this.Value)) return this;
}
this.Trip = true;
LogMessage(message);
return this;
}
}
}
这一切是如何工作的?
如果您没有在 Github 上深入研究 Microsoft 的 AspNetCore 代码,研究所有编辑内容是如何组合在一起的,可能会有点令人费解。
编辑表单组件之间存在一套复杂的关.和链接,使这一切得以实现。最终结果是大量协调的组件重新渲染,以显示验证问题,并在正确的时间显示正确的按钮。
我们已经涵盖了表单的初始加载过程。<UILoader Loaded="this.IsLoaded">
控制表单何时渲染。一旦我们有了活动的 EditContext
和互联的 RecordEditorContext
,IsLoaded
就为 true。所有输入控件都会被渲染并链接到级联的 EditContext
。第一次调用 RecordEditorContext
上的 NotifyEditContextChangedAsync
会进行验证,因此表单将显示初始验证消息。
假设我们更改了 Summary。这是来自 InputBase
的重要代码片段。
// Code snippet from InputBase.cs
protected TValue? CurrentValue
{
get => Value;
set
{
var hasChanged = !EqualityComparer<TValue>.Default.Equals(value, Value);
if (hasChanged)
{
Value = value;
_ = ValueChanged.InvokeAsync(Value);
EditContext.NotifyFieldChanged(FieldIdentifier);
}
}
}
在 Summary 编辑控件退出时,InputText
控件设置 Value = value
(Value
是 RecordEditorContext
中的属性值),调用自己的 ValueChanged
事件,然后调用 EditContext
上的 NotifyFieldChanged
。这会引发两个过程。
RecordEditorContext 属性设置
在 RecordEditorContext
中设置的属性将 RecordFieldValue
的 EditedValue
设置为新值,然后启动 Validate
,后者执行验证。Set、Validate
和后续验证都是同步操作。它们在调用 EditContext
上的 NotifyFieldChanged
之前完成。这一点很重要:验证过程在 EditContext
运行代码或触发任何事件之前就已完成。Validate
的最后一步是通知 EditContext
验证状态已更改 - this.EditContext.NotifyValidationStateChanged()
。
// Code snippet from EditContext.cs
public void NotifyValidationStateChanged()
{
OnValidationStateChanged?.Invoke(this, ValidationStateChangedEventArgs.Empty);
}
EditContext
启动自己的 OnValidationStateChanged
事件。所有输入控件和 ValidationMessage
实例都像下面展示的那样连接到此事件。输入控件检查与它们相关的验证消息,并在存在时改变颜色并渲染。ValidationMessage
实例查找它们相关的消息并在找到时显示。
public override Task SetParametersAsync(ParameterView parameters)
{
....
EditContext.OnValidationStateChanged += _validationStateChangedHandler;
...
}
所有受影响的字段都会收到更改通知,并可以单独更新和重新渲染它们。
EditContext.FieldChanged
输入控件向 NotifyFieldChanged
传递一个 FieldIdentifier
对象 - 它链接到的属性名称和 Model
对象 - 在我们的例子中是 RecordEditorContext
。EditContext
更新其内部的 FieldStates
集合,将 FieldIdentifier
记录为与 FieldIdentifier
关联的 FieldState
对象中的 IsModified
。最后,它触发 OnFieldChanged
事件。下面的代码片段显示了 EditContext
中的 NotifyFieldChanged
。
// Code snippet from EditContext.cs
public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier)
{
GetOrAddFieldState(fieldIdentifier).IsModified = true;
OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier));
}
最后一点操作发生在编辑表单中。本地方法 OnFieldChanged
连接到 EditContext.OnFieldChanged
。
// Code snippet from WeatherForecastEditor.razor.cs
protected void OnFieldChanged(object sender, EventArgs e)
=> this.SetLock();
private void SetLock()
{
this.IsDirtyExit = false;
if (this.RecordEditorContext.IsDirty)
this.Modal.Lock(true);
else
this.Modal.Lock(false);
InvokeAsync(StateHasChanged);
}
SetLock
清除 IsDirtyExit
- 如果我们的操作是尝试退出已修改的表单,则设置此项。然后我们检查 RecordEditorContext
是否已修改。在我们的例子中,是的,所以我们锁定浏览器窗口。最后,我们渲染控件,这会处理按钮。请注意,如果我们已经编辑过一次值,然后又将其改回原始值,RecordEditorContext
将是干净的。您可以退出,**Update** 按钮将消失。
WeatherForecast Viewer
我将不详细介绍。您可以在仓库中查看代码,了解其结构。它是编辑器的简化版本,直接访问控制器服务记录以获取其数据,并使用自定义的 InputReadOnlyText
组件来显示值。
总结
我在这里构建的许多基础设施都比较简单。服务和数据记录应该使用接口和核心抽象类来提供抽象并实现样板代码。一系列文章 - Building a Database Application - 更详细地介绍了这样一个框架。请注意,当前文章集基于我四个月前的 NetCore 3.1 框架,并将很快进行修订。
我在这里介绍的是一种编辑记录的方法。它不适合所有人。它取决于您对数据的思维方式以及您工作的环境。即便如此,我希望它能引发一些关于您如何看待和处理数据的思考。
历史
- 2021 年 2 月 16 日:初始版本