65.9K
CodeProject 正在变化。 阅读更多。
Home

构建 Blazor 编辑器框架

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2021 年 2 月 16 日

CPOL

12分钟阅读

viewsIcon

12884

如何为 Blazor 表单构建编辑器框架

引言

这是两篇关于如何在 Blazor 中实现编辑表单的文章中的第二篇。

第一篇文章探讨了如何控制用户在表单被修改后可以做什么;本质上,是如何防止用户意外退出。本文介绍如何构建一个框架,该框架能够检测数据集何时被修改或无效,并“锁定”应用程序。

许多人可能认为 Blazor 已经具备了足够的编辑数据功能。为什么要重新发明轮子是一个有效的问题?如果您坚信这一点,请不要再往下看了:这篇文章不适合您。如果不是,请继续阅读并自己做出决定。

一些近期的背景。C# 9 引入了 Record 类型,创建了一种不可变的引用类型。{get; init;} 属性允许我们创建不可变的属性。这些是最近的语言更改:微软在重新思考?

我坚信要保持从数据库读取的记录和记录集的完整性。您在参考记录或记录集中看到的内容就是数据库中的内容。如果您想编辑某项内容,有一个过程,不要随意更改原始内容。制作一份副本,更改副本,将副本提交到数据库,然后从数据库刷新您的参考数据。

我使用的编辑框架(本文中所述)实现了这些原则。

Dirty Editor

概述

这个简短的讨论和项目使用开箱即用的 Blazor WeatherForecast record 作为我们的示例。

DbWeatherForecast 代表从数据库读取的记录。它被声明为 class,而不是 record:只有表示数据库字段的属性是不可变的。DbWeatherForecast 的可编辑版本保存在 RecordCollection 中。DbWeatherForecast 具有构建和从 RecordCollection 读取数据的 . 方法。RecordCollection 是一个 IEnumerable 对象,其中包含 RecordFieldValue 对象的列表。每个对象代表 DbWeatherForecast 中的一个字段/属性。RecordFieldValue 具有自己的不可变字段 ValueFieldName,以及一个可以设置的 EditedValue 字段。IsDirty 是一个布尔属性,表示 RecordFieldValue 的编辑状态。RecordCollectionRecordFieldValue 类提供了对底层数据值的受控访问。

WeatherForecastEditContextDbWeatherForecast 的 UI 编辑器对象,它公开了 DbWeatherForecastRecordCollection 的可编辑属性。它与 EditContext 具有共生关系,跟踪 RecordCollection 的编辑状态并提供任何需要数据验证的属性的验证。

在项目中,WeatherForecastControllerService 是提供对 WeatherForecast 数据访问的业务对象。编辑器和查看器调用 GetForecastAsync(id) 来加载 WeatherForecastControllerService 中的当前 DbWeatherForecast 记录。RecordData,即 DbWeatherForecast 记录的 RecordCollection,由 GetForecastAsync(id) 填充。当 UI 初始化 WeatherForecastEditContext 的实例时,它会向其传递 WeatherForecastControllerServiceRecordData RecordCollection。此时需要注意的是,当加载新的 DbWeatherForecast 时,RecordData 不会被替换,而是会被清空然后重新填充:传递给 WeatherForecastEditContext 的引用始终有效。

示例代码

一如既往,有一个 GitHub 仓库 CEC.Blazor.EditorCEC.Blazor.ModalEditor 是本文的项目。

基础设施类

一如既往,我们需要一些支持类来完成主要任务。

RecordFieldValue

如前所述,RecordFieldValue 存储有关记录集字段的信息。

注意

  1. 派生自实际记录的属性是 {get; init;}。它们只能在创建 RecordFieldValue 实例时设置。
  2. FieldName 是字段的属性名称。我们定义它以确保在整个应用程序中使用相同的 string 值。
  3. Value 是字段的数据库值。
  4. ReadOnly 不言自明。它用于标记派生/计算字段。
  5. DisplayName 是显示字段名称时使用的 string
  6. EditedValue 是我们编辑上下文中字段的当前值。getter 确保在第一次 get 时,如果尚未 set,则将其设置为 Value
  7. IsDirtyValueEditedValue 执行默认的相等性检查,以确定 Field 是否已修改。
  8. ResetEditedValue 设置回 Value
  9. 两个 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

RecordCollectionRecordFieldValue 对象的托管 IEnumerable 集合。

注意

  1. 有许多 getter、setter 等用于访问和更新各个 RecordFieldValue 对象。
  2. IsDirty 检查集合中是否有任何已修改的项目。
  3. 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 中详细介绍它。需要注意的关键点:

  1. 它的初始化器需要一个 RecordCollection 对象。在应用程序中,这是 ControllerServiceRecordCollection,称为与当前记录关联的 RecordData。每当记录更改时,它都会加载。
  2. 它保存对有效 EditContext 的引用,并期望收到更改通知。
  3. 它处理 EditContext 的验证,并连接到 EditContext.OnValidationRequested
  4. 它保存一个 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

新的天气预报记录。虽然我们只即时创建这些记录,但普通应用程序会从数据库获取它们。

注意

  1. 类中有一个 static 声明的 RecordFieldValue,用于每个数据库属性/字段。在大型应用程序中,这些应该在中央的 DataDictionary 中声明。
  2. Database”属性全部声明为 { get; init; }:它们是不可变的。
  3. AsRecordCollection 从记录构建一个 RecordCollection 对象。
  4. FromRecordCollectionstatic 的,它使用编辑后的值从提供的 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

数据服务

  1. 在启动时构建虚拟数据集。
  2. 在该数据集上提供 CRUD 数据操作。
  3. 我们使用 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。它

  1. 具有三个 RenderFragments
  2. LoadingContent 仅在表单加载时显示。
  3. EditorContent 在加载完成后显示。它级联 EditContext
  4. ButtonContent 始终显示在控件底部。
  5. Loaded 控制渲染的内容。
  6. 我们使用 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。它很相似。EditView 按钮现在传递记录的 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>
}

在代码中

  1. 我们现在使用新的 WeatherForecastControllerService,而 Razor 标记使用服务 Forecasts 列表。
  2. 我们在 WeatherForecastControllerService 的表单 OnInitializedAsync() 中加载 Forecasts 列表。
  3. 两个按钮处理程序创建一个 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

编辑器是设置所有编辑组件的容器,然后根据底层 EditContextRecordEditorContext 的变化更新 UI 中的按钮。一组布尔属性控制 UI 和按钮状态。

初始化时,它

  1. ModalOptions 获取记录 ID
  2. 加载控制器 DbWeatherForecast - 这会加载服务中的 RecordData,即 RecordCollection 对象
  3. 创建一个新的 RecordEditorContext,传入 RecordCollection
  4. 创建一个 EditContext,并将 RecordEditorContext 作为模态框
  5. 通知 RecordEditorContext EditContext 已更改
  6. EditContext.OnFieldChanged 事件连接到本地的 OnFieldChanged 事件处理程序

需要注意的关键点:

  1. 编辑器将本地属性连接到级联的 ModalDialog,以便它可以锁定和解锁表单并退出。
  2. 包含保存和各种退出方法。
  3. 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();
    }
}

标记代码是相当标准的编辑器内容。

  1. 您可以看到 UIComponents 的使用,以标准化 HTML。
  2. EditFormModalEditForm 替换。它确保内容在加载前不会渲染,并级联 EditContext
  3. 按钮使用各种布尔属性来控制它们的显示状态。
@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

注意

  1. 暴露 RecordValues 中底层字段的属性,这些属性一直引用到 ControllerService 中的 RecordCollection 对象。
  2. 属性 setter 设置 RecordFieldValue 上的 EditedValue
  3. 属性 setter 调用 Validate 并触发整个表单编辑组件中的验证过程 - 将任何控件变成红色并显示任何验证消息。
  4. 为需要验证的属性定义的 Validators
  5. 通过 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

这是一个用于 stringValidator

验证器工作原理的关键是 static 类。Validationstring 的扩展方法。当您在 string 上调用 Validation 时,它会创建一个 StringValidator 对象并返回它。现在您有了一个 StringValidator,您可以在其上调用验证方法。每个验证方法都会返回对 validation 对象的引用。您可以将任意数量的验证方法链接在一起,并附带其特定的消息。您调用基类 Validate 方法来完成该过程。它将任何验证消息记录到 ValidationMessageStore 中,并返回 truefalseValidationMessageStoreEditContext 相关联。

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 和互联的 RecordEditorContextIsLoaded 就为 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 = valueValueRecordEditorContext 中的属性值),调用自己的 ValueChanged 事件,然后调用 EditContext 上的 NotifyFieldChanged。这会引发两个过程。

RecordEditorContext 属性设置

RecordEditorContext 中设置的属性将 RecordFieldValueEditedValue 设置为新值,然后启动 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 对象 - 在我们的例子中是 RecordEditorContextEditContext 更新其内部的 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 日:初始版本
© . All rights reserved.