Effective config: multi descriptor refactor, validation UX, restore cleanup

- All multi fields use EffectiveMultiValueConfigFieldDescriptor; registry has
  RegisterMultiDescriptor/RegisterMultiComponent/RegisterMulti; every multi
  option registered; plain multi fallback removed (AddMultiDescriptor throws).
- Descriptor-driven validation: base descriptor ValidateValue + Validate();
  integer descriptor enforces Min/Max; ListenAddressMultiValidator for
  listen-address; single/flag commit uses Descriptor.Validate (no session
  field issues for inline edits).
- Multi list editor: local _displayItems so remove/add/edit update UI
  immediately; remove button PreventBlurOnMouseDown; inline validation
  message under input (ec-multi-list-input-group); root div not span.
- Save flow: on successful backup restore call OnCloseAndEndEdit then
  OnSaveCompleted so session and pending changes are cleared and config
  is reloaded.
- Server uses descriptor-only (no custom multi component); ServerMultiValueDisplay
  removed. RegisterValidatedSingle added for future single-field validation.
This commit is contained in:
2026-03-07 21:11:12 +10:00
parent a9f8961c32
commit ff94be87b2
28 changed files with 750 additions and 106 deletions
@@ -3,6 +3,7 @@
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
@inject IEffectiveConfigEditSession Session
@inject IJSRuntime JSRuntime
@@ -17,6 +18,7 @@
}
<EffectiveConfigToolbar ShowSearchBox="@ShowSearchBox" SearchTerm="@_searchTerm" SearchTermChanged="@OnSearchTermChanged"
IsEditMode="@Session.IsEditMode" PendingChangesCount="@Session.PendingChanges.Count"
ValidationErrorCount="@_validationErrorCount" ValidationWarningCount="@_validationWarningCount"
AllSectionsExpanded="@_allSectionsExpanded"
OnExpandAll="ExpandAll" OnCollapseAll="CollapseAll" OnEnterEditMode="EnterEditMode" OnExitEditModeWithConfirm="ExitEditModeWithConfirmAsync" OnOpenSaveModal="OpenSaveModal" />
<CascadingValue Value="@Session">
@@ -40,6 +42,9 @@
private HashSet<string> _openPanels = new();
private bool _showSaveModal;
private int _validationErrorCount => Session.GetValidationSummary().Count(i => i.Severity == FieldIssueSeverity.Error);
private int _validationWarningCount => Session.GetValidationSummary().Count(i => i.Severity == FieldIssueSeverity.Warning);
private string? _optionHelpKey;
private string? _optionHelpAnchorId;
private string _optionHelpTitle = "Option help";
@@ -69,7 +74,7 @@
ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; },
DeactivateFieldAsync = () => { Session.DeactivateField(); return InvokeAsync(StateHasChanged); },
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; },
RevertFieldAsync = (sectionId, optionName) => { Session.RevertChange(sectionId, optionName); return InvokeAsync(StateHasChanged); }
RevertFieldAsync = (sectionId, optionName) => { Session.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); }
};
}
@@ -184,9 +189,16 @@
private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args)
{
Session.TrackCommit(args);
RunCrossOptionEvaluator();
StateHasChanged();
}
private void RunCrossOptionEvaluator()
{
var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, Session.PendingChanges);
Session.SetCrossOptionIssues(issues);
}
private void OpenSaveModal()
{
if (Session.PendingChanges.Count == 0) return;
@@ -30,6 +30,20 @@
@PendingChangesCount change(s)
</span>
}
@if (ValidationErrorCount > 0 || ValidationWarningCount > 0)
{
<span class="small ec-toolbar-validation-count" aria-live="polite">
@if (ValidationErrorCount > 0)
{
<span class="text-danger fw-medium">errors: @ValidationErrorCount</span>
}
@if (ValidationErrorCount > 0 && ValidationWarningCount > 0) { <span class="text-muted">·</span> }
@if (ValidationWarningCount > 0)
{
<span class="text-warning">warnings: @ValidationWarningCount</span>
}
</span>
}
}
else
{
@@ -46,6 +60,8 @@
[Parameter] public EventCallback<string> SearchTermChanged { get; set; }
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public int PendingChangesCount { get; set; }
[Parameter] public int ValidationErrorCount { get; set; }
[Parameter] public int ValidationWarningCount { get; set; }
[Parameter] public bool AllSectionsExpanded { get; set; }
[Parameter] public EventCallback OnExpandAll { get; set; }
[Parameter] public EventCallback OnCollapseAll { get; set; }
@@ -34,9 +34,20 @@ else
private async Task OnInput(ChangeEventArgs e)
{
int? value = null;
if (int.TryParse(e.Value?.ToString(), out var num) && num >= Min && num <= Max)
var raw = e.Value?.ToString();
object? value = null;
if (string.IsNullOrWhiteSpace(raw))
{
value = null;
}
else if (int.TryParse(raw, out var num))
{
value = num;
}
else
{
value = raw;
}
await NotifyValueChanged(value);
}
}
@@ -5,8 +5,6 @@
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@using System.Net
@using System.Net.Sockets
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@inject INotificationService NotificationService
@@ -44,11 +42,32 @@
}
else if (Descriptor.IsMultiValue)
{
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
Source="@_multiValueRowSource" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
var customMulti = RenderFragmentRegistry.BuildMultiFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<IReadOnlyList<string>>(this, HandleMultiValueChanged));
if (customMulti is not null)
{
@* Custom multi editor (e.g. server): same row shell as generic multi, value slot uses custom fragment. *@
<li class="ec-field @(IsEditMode && IsActive ? "ec-field-editing" : "")">
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
<span class="ec-field-value">
<CascadingValue Value="@(IsEditMode && IsActive)">
<CascadingValue Value="@IsEditMode">
@customMulti(Descriptor)
</CascadingValue>
</CascadingValue>
</span>
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiValueRowSource" OptionName="@Descriptor.OptionName" Value="@_multiValueItems"
IsInteractive="@IsEditMode" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive)"
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
</li>
}
else
{
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
<EffectiveConfigMultiValueRow Descriptor="@Descriptor" Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
Source="@_multiValueRowSource" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
}
else
{
@@ -191,7 +210,7 @@ else
await Ui.RevertFieldAsync(Descriptor.SectionId, Descriptor.OptionName);
return true;
}
var validationError = ValidateDraftValue(newValue);
var validationError = Descriptor.Validate(newValue);
if (validationError != null)
{
NotificationService.ShowError(validationError);
@@ -261,22 +280,6 @@ else
await Ui.DeactivateFieldAsync();
}
private string? ValidateDraftValue(object? value)
{
if (string.Equals(Descriptor.OptionName, DnsmasqConfKeys.ListenAddress, StringComparison.Ordinal))
{
var values = AsStringList(value) ?? (value is string s ? new[] { s } : null);
if (values == null) return null;
foreach (var raw in values)
{
var v = raw.Trim();
if (!IPAddress.TryParse(v, out var ip) || (ip.AddressFamily != AddressFamily.InterNetwork && ip.AddressFamily != AddressFamily.InterNetworkV6))
return $"Invalid value for {DnsmasqConfKeys.ListenAddress}: '{raw}'. Use a valid IPv4 or IPv6 address.";
}
}
return null;
}
private static IReadOnlyList<string>? AsStringList(object? value)
{
return value as IReadOnlyList<string>;
@@ -2,33 +2,44 @@
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using Microsoft.AspNetCore.Components.Web
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
@inject IJSRuntime JSRuntime
<span class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
<div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
<ol class="ec-field-value-list">
@if (SourceItems?.Count > 0)
@if (_displayItems.Count > 0)
{
@for (var i = 0; i < SourceItems.Count; i++)
@for (var i = 0; i < _displayItems.Count; i++)
{
var item = SourceItems[i];
var item = _displayItems[i];
var anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}";
var itemReadonly = item.Source?.IsReadOnly == true;
var rowIndex = i;
var rowValue = item.Value;
var rowInlineError = GetInlineErrorForIndex(rowIndex);
<li class="ec-multi-list-row" @key="anchorId">
<li class="ec-multi-list-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-multi-list-row-with-controls" : "")" @key="anchorId">
@if (_editingIndex == rowIndex && !itemReadonly)
{
<input class="form-control form-control-sm ec-multi-list-input"
@bind="_draftValue" @bind:event="oninput" />
<span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
Title="Apply change" AriaLabel="Apply change"
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard edit" AriaLabel="Cancel edit"
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
</span>
<div class="ec-multi-list-row-controls">
<div class="ec-multi-list-input-group">
<input class="form-control form-control-sm ec-multi-list-input @(rowInlineError != null ? "is-invalid" : "")"
placeholder="@(Behavior?.Placeholder ?? "")"
@bind="_draftValue" @bind:event="oninput" />
@if (rowInlineError != null)
{
<div class="ec-multi-list-invalid-feedback invalid-feedback d-block">@rowInlineError</div>
}
</div>
<span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
Title="Apply change" AriaLabel="Apply change"
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard edit" AriaLabel="Cancel edit"
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
</span>
</div>
}
else
{
@@ -48,6 +59,7 @@
{
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
Title="Remove this value" AriaLabel="Remove"
PreventBlurOnMouseDown="true"
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
}
</span>
@@ -64,20 +76,28 @@
@if (AllowAddManagedValue)
{
<li class="ec-multi-list-row ec-multi-list-add-item" @key="AddItemKey">
<li class="ec-multi-list-row ec-multi-list-add-item @(_addingNew ? "ec-multi-list-row-with-controls" : "")" @key="AddItemKey">
@if (_addingNew)
{
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input"
placeholder="New value"
@bind="_draftValue" @bind:event="oninput" />
<span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
Title="Add this value" AriaLabel="Add"
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard" AriaLabel="Cancel"
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
</span>
<div class="ec-multi-list-row-controls">
<div class="ec-multi-list-input-group">
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input @(AddInlineError != null ? "is-invalid" : "")"
placeholder="@(Behavior?.Placeholder ?? "New value")"
@bind="_draftValue" @bind:event="oninput" />
@if (AddInlineError != null)
{
<div class="ec-multi-list-invalid-feedback invalid-feedback d-block">@AddInlineError</div>
}
</div>
<span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
Title="Add this value" AriaLabel="Add"
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard" AriaLabel="Cancel"
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
</span>
</div>
}
else
{
@@ -88,7 +108,7 @@
</li>
}
</ol>
</span>
</div>
@code {
[Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; }
@@ -98,6 +118,10 @@
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public bool AllowAddManagedValue { get; set; } = true;
[Parameter] public IMultiValueEditBehavior? Behavior { get; set; }
[Parameter] public IMultiValueOptionValidator? Validator { get; set; }
[Parameter] public string? FieldKey { get; set; }
[Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
private const string AddItemKey = "add-item";
@@ -107,9 +131,22 @@
private bool _didAutoOpenAdd;
private bool _shouldFocusAddInput;
private ElementReference _addNewInputRef;
private int? _inlineErrorIndex;
private string? _inlineErrorMessage;
private List<ValueWithSource> _displayItems = new();
private IReadOnlyList<string> _currentValues => _displayItems.Select(x => x.Value).ToList();
private string? AddInlineError => _addingNew && _inlineErrorIndex is null ? _inlineErrorMessage : null;
private string? GetInlineErrorForIndex(int? editIndex)
{
return _editingIndex == editIndex && _inlineErrorIndex == editIndex ? _inlineErrorMessage : null;
}
protected override void OnParametersSet()
{
_displayItems = (SourceItems ?? Array.Empty<ValueWithSource>()).ToList();
if (!_didAutoOpenAdd && (SourceItems?.Count ?? 0) == 0 && AllowAddManagedValue)
{
_didAutoOpenAdd = true;
@@ -139,6 +176,7 @@
_addingNew = false;
_editingIndex = index;
_draftValue = current;
ClearInlineError();
}
private void BeginAdd()
@@ -147,6 +185,7 @@
_addingNew = true;
_draftValue = "";
_shouldFocusAddInput = true;
ClearInlineError();
}
private void CancelInline()
@@ -154,46 +193,96 @@
_editingIndex = null;
_addingNew = false;
_draftValue = "";
ClearInlineError();
}
private async Task SaveEdit(int index)
{
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
.Select(x => x.Value)
.ToList();
var current = _currentValues;
var next = current.ToList();
if (index < 0 || index >= next.Count) return;
next[index] = (_draftValue ?? "").Trim();
var raw = _draftValue ?? "";
var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
if (normalized.Length == 0)
{
SetInlineError(index, "Value cannot be empty.");
return;
}
var validationError = Validator?.ValidateItem(normalized, current, index);
if (validationError != null)
{
SetInlineError(index, validationError);
return;
}
if (Behavior?.AllowDuplicates == false && next.Where((v, i) => i != index).Any(v => string.Equals(v, normalized, StringComparison.Ordinal)))
{
SetInlineError(index, "Duplicate values are not allowed.");
return;
}
ClearInlineError();
next[index] = normalized;
_displayItems[index] = _displayItems[index] with { Value = normalized };
await OnValuesChanged.InvokeAsync(next);
CancelInline();
}
private async Task SaveNew()
{
var value = (_draftValue ?? "").Trim();
if (value.Length == 0) return;
var raw = _draftValue ?? "";
var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
if (normalized.Length == 0) return;
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
.Select(x => x.Value)
.ToList();
var current = _currentValues;
var validationError = Validator?.ValidateItem(normalized, current, null);
if (validationError != null)
{
SetInlineError(null, validationError);
return;
}
next.Add(value);
if (Behavior?.AllowDuplicates == false && current.Contains(normalized, StringComparer.Ordinal))
{
SetInlineError(null, "Duplicate values are not allowed.");
return;
}
ClearInlineError();
var next = current.ToList();
next.Add(normalized);
_displayItems.Add(new ValueWithSource(normalized, null));
await OnValuesChanged.InvokeAsync(next);
CancelInline();
}
private void SetInlineError(int? itemIndex, string message)
{
_inlineErrorIndex = itemIndex;
_inlineErrorMessage = message;
StateHasChanged();
}
private void ClearInlineError()
{
_inlineErrorIndex = null;
_inlineErrorMessage = null;
}
private async Task RemoveAt(int index)
{
if (SourceItems == null || index < 0 || index >= SourceItems.Count) return;
var item = SourceItems[index];
if (index < 0 || index >= _displayItems.Count) return;
var item = _displayItems[index];
if (item?.Source?.IsReadOnly == true) return;
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
.Select(x => x.Value)
.ToList();
var next = _displayItems.Select(x => x.Value).ToList();
if (index < 0 || index >= next.Count) return;
next.RemoveAt(index);
_displayItems.RemoveAt(index);
await OnValuesChanged.InvokeAsync(next);
StateHasChanged();
}
}
@@ -20,6 +20,33 @@
min-height: 2rem;
}
.ec-multi-list-row.ec-multi-list-row-with-controls {
align-items: flex-start;
}
.ec-multi-list-row-controls {
display: flex;
align-items: flex-start;
gap: 0.5rem;
flex-wrap: nowrap;
min-width: 0;
width: 100%;
}
.ec-multi-list-input-group {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
max-width: 28rem;
}
.ec-multi-list-invalid-feedback {
margin-top: 0.25rem;
margin-bottom: 0;
width: 100%;
}
.ec-multi-list-row .ec-field-value-item {
min-width: 0;
}
@@ -32,8 +59,9 @@
}
.ec-multi-list-row .form-control.ec-multi-list-input {
max-width: 28rem;
max-width: none;
flex-shrink: 0;
width: 100%;
}
.ec-multi-list-empty-hint {
@@ -51,11 +79,13 @@
}
.ec-multi-list-add-item .form-control.ec-multi-list-input {
flex: 1 1 auto;
max-width: none;
min-width: 12rem;
}
.ec-multi-list-add-item .ec-multi-list-input-group {
max-width: none;
}
.ec-multi-list-add-item .status-badge-inline {
margin-left: auto;
}
@@ -12,6 +12,9 @@
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
IsEditMode="@IsEditMode"
AllowAddManagedValue="@IsEditMode"
FieldKey="@FieldKey"
Behavior="@(_multiDesc?.Behavior)"
Validator="@(_multiDesc?.Validator)"
OnValuesChanged="@ItemsChanged" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
@@ -20,7 +23,7 @@
}
else
{
<span class="ec-field-value ec-field-value-multi">
<div class="ec-field-value ec-field-value-multi">
@if (Items?.Count > 0)
{
<ol class="ec-field-value-list">
@@ -37,12 +40,13 @@
<OptionStateBadge OptionName="@Label" ShowEditableBadge="true" EditableAsButton="true"
FieldKey="@FieldKey" IsActiveEditor="false" IsInteractive="true" />
}
</span>
</div>
}
</li>
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public EffectiveConfigFieldDescriptor? Descriptor { get; set; }
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
[Parameter] public string? LabelTooltip { get; set; }
@@ -58,4 +62,6 @@
[Parameter] public EventCallback OnConfirmRequested { get; set; }
[Parameter] public EventCallback OnCancelRequested { get; set; }
[Parameter] public EventCallback OnRevertRequested { get; set; }
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc => Descriptor as EffectiveMultiValueConfigFieldDescriptor;
}
@@ -64,6 +64,11 @@
private async Task HandleSave()
{
if (Session.PendingChanges.Count == 0) return;
if (Session.HasBlockingValidationErrors())
{
NotificationService.ShowError("Fix validation errors before saving.");
return;
}
_saveState = SaveUiState.Saving;
_saveResult = null;
@@ -114,7 +119,7 @@
_saveState = SaveUiState.Review;
_saveResult = null;
_restoreResult = null;
await OnClose.InvokeAsync();
await OnCloseAndEndEdit.InvokeAsync();
await OnSaveCompleted.InvokeAsync();
return;
}