mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-20 06:41:29 +10:00
Unify parser, editor, and save-time validation around semantic metadata and handlers, add path-aware and structured option checks, and improve effective-config editing placeholders and unset behavior.
331 lines
17 KiB
Plaintext
331 lines
17 KiB
Plaintext
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
|
@using DnsmasqWebUI.Models.Dnsmasq
|
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
|
@using DnsmasqWebUI.Models.Config
|
|
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
|
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
|
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
|
@inject INotificationService NotificationService
|
|
|
|
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. Pending value shown until save. *@
|
|
@if (_customFragment is { } fragment)
|
|
{
|
|
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
|
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
|
|
var showEditableBadge = !isReadonly && !Descriptor.IsCapabilityDisabled;
|
|
var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
|
|
var fragmentWithCallback = isActiveEditor ? _fragmentWithCallback : null;
|
|
var effectiveValue = _effectiveValue;
|
|
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
|
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
|
|
<span class="ec-field-value">
|
|
@if (isActiveEditor)
|
|
{
|
|
<div class="ec-field-value-when-editing" @onclick:stopPropagation="true">
|
|
<div class="ec-field-edit-wrapper">
|
|
<CascadingValue Value="@true">
|
|
<CascadingValue Value="@(EventCallback.Factory.Create<FocusEventArgs>(this, OnBlur))">
|
|
@((fragmentWithCallback ?? fragment)(Descriptor))
|
|
</CascadingValue>
|
|
</CascadingValue>
|
|
</div>
|
|
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<CascadingValue Value="@false">
|
|
@fragment(Descriptor)
|
|
</CascadingValue>
|
|
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
|
}
|
|
</span>
|
|
</li>
|
|
}
|
|
else if (Descriptor.IsMultiValue)
|
|
{
|
|
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 && !Descriptor.IsCapabilityDisabled ? "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 && !Descriptor.IsCapabilityDisabled)">
|
|
<CascadingValue Value="@IsEditMode">
|
|
@customMulti(Descriptor)
|
|
</CascadingValue>
|
|
</CascadingValue>
|
|
</span>
|
|
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiValueRowSource" OptionName="@Descriptor.OptionName" Value="@_multiValueItems"
|
|
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)"
|
|
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
|
|
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 && !Descriptor.IsCapabilityDisabled)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
|
Source="@_multiValueRowSource" ShowEditableBadge="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
|
|
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var value = _effectiveValue;
|
|
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
|
|
var editValue = value?.ToString() ?? "";
|
|
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
|
|
var showReadonlyHint = IsEditMode && isReadonly;
|
|
var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
|
|
var showEditableForDefault = !Descriptor.IsCapabilityDisabled;
|
|
@if (showReadonlyHint)
|
|
{
|
|
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
|
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
|
FieldKey="@FieldKey" ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange" />
|
|
</li>
|
|
}
|
|
else
|
|
{
|
|
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
|
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
|
EditValue="@editValue"
|
|
ShowEditableBadge="@showEditableForDefault" FieldKey="@FieldKey" IsActive="@IsActive"
|
|
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange"
|
|
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
|
|
DisplayValueChanged="@HandleDefaultValueChanged" OnBlur="@HandleDefaultBlur" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
|
</li>
|
|
}
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
|
[Parameter] public string FieldKey { get; set; } = "";
|
|
[Parameter] public bool IsEditMode { get; set; }
|
|
[Parameter] public bool IsActive { get; set; }
|
|
[CascadingParameter] public IEffectiveConfigEditSession? Session { get; set; }
|
|
|
|
private readonly string _anchorId = "ec-help-" + Guid.NewGuid().ToString("N")[..8];
|
|
private readonly string _readonlyBadgeAnchorId = "ec-readonly-" + Guid.NewGuid().ToString("N")[..8];
|
|
private RenderFragment<EffectiveConfigFieldDescriptor>? _customFragment;
|
|
private RenderFragment<EffectiveConfigFieldDescriptor>? _fragmentWithCallback;
|
|
private string? _labelTooltip;
|
|
private string? _optionHelpKey;
|
|
private object? _effectiveValue;
|
|
private bool _hasPendingChange;
|
|
private object? _valueAtActivation;
|
|
private object? _draftValue;
|
|
private bool _hasDraftValue;
|
|
private bool _wasActive;
|
|
private ExitAction _exitAction = ExitAction.None;
|
|
|
|
/// <summary>Cached for multi-value: items to pass to the row (with sources preserved for draft merge).</summary>
|
|
private IReadOnlyList<ValueWithSource>? _multiValueItems;
|
|
/// <summary>Cached for multi-value: true when there is an uncommitted draft list.</summary>
|
|
private bool _multiValueHasDraft;
|
|
/// <summary>Cached for multi-value: row-level source for badge (readonly when all items readonly and not edit mode).</summary>
|
|
private ConfigValueSource? _multiValueRowSource;
|
|
/// <summary>Cached for multi-value: whether row should show ok/cancel (session pending or local draft).</summary>
|
|
private bool _multiValueHasPendingOrDraft;
|
|
|
|
protected override void OnParametersSet()
|
|
{
|
|
_customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName);
|
|
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
|
|
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
|
_optionHelpKey = Descriptor.OptionName;
|
|
var pending = Session?.PendingChanges.FirstOrDefault(c =>
|
|
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
|
var descriptorValue = Descriptor.IsMultiValue
|
|
? (Descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List<string>())
|
|
: Descriptor.GetValue();
|
|
_hasPendingChange = pending != null;
|
|
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
|
|
if (Descriptor.IsMultiValue)
|
|
ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
|
if (!_wasActive && IsActive)
|
|
{
|
|
// Use pending.OldValue when present so "revert to original" clears the pending change (e.g. do-0x20-encode: Disabled → Enable → Disable).
|
|
_valueAtActivation = pending != null ? pending.OldValue : descriptorValue;
|
|
_hasDraftValue = false;
|
|
_draftValue = null;
|
|
}
|
|
if (_wasActive && !IsActive && _exitAction == ExitAction.None)
|
|
_ = InvokeAsync(CommitIfChangedAsync);
|
|
if (_wasActive && !IsActive)
|
|
_exitAction = ExitAction.None;
|
|
_wasActive = IsActive;
|
|
}
|
|
|
|
private void ComputeMultiValueState(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string>? pendingList, IReadOnlyList<string>? draftList)
|
|
{
|
|
var descriptorValues = descriptorItems?.Select(i => i.Value).ToList() ?? new List<string>();
|
|
_multiValueHasDraft = draftList != null && !ValuesEqual(descriptorValues, draftList);
|
|
if (_multiValueHasDraft && draftList != null)
|
|
{
|
|
_multiValueItems = BuildItemsWithSource(descriptorItems, draftList);
|
|
}
|
|
else if (_hasPendingChange && pendingList != null)
|
|
{
|
|
_multiValueItems = BuildItemsWithSource(descriptorItems, pendingList);
|
|
}
|
|
else
|
|
{
|
|
_multiValueItems = descriptorItems;
|
|
}
|
|
var allReadonly = _multiValueItems != null && _multiValueItems.Count > 0 && _multiValueItems.All(i => i.Source?.IsReadOnly == true);
|
|
_multiValueRowSource = allReadonly && !IsEditMode ? _multiValueItems?.FirstOrDefault()?.Source : null;
|
|
_multiValueHasPendingOrDraft = _hasPendingChange || _multiValueHasDraft;
|
|
}
|
|
|
|
private static IReadOnlyList<ValueWithSource> BuildItemsWithSource(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string> values)
|
|
{
|
|
var pool = descriptorItems?.ToList() ?? new List<ValueWithSource>();
|
|
var list = new List<ValueWithSource>(values.Count);
|
|
foreach (var s in values)
|
|
{
|
|
var idx = pool.FindIndex(x => string.Equals(x.Value, s, StringComparison.Ordinal));
|
|
if (idx >= 0)
|
|
{
|
|
list.Add(pool[idx]);
|
|
pool.RemoveAt(idx);
|
|
}
|
|
else
|
|
list.Add(new ValueWithSource(s, null));
|
|
}
|
|
return list;
|
|
}
|
|
|
|
private void HandleValueChanged(object? value)
|
|
{
|
|
_draftValue = value;
|
|
_hasDraftValue = true;
|
|
}
|
|
|
|
private async Task OnBlur(FocusEventArgs _)
|
|
{
|
|
if (!IsActive) return;
|
|
await CommitIfChangedAsync();
|
|
}
|
|
|
|
private async Task<bool> CommitIfChangedAsync()
|
|
{
|
|
var newValue = _hasDraftValue ? _draftValue : _effectiveValue;
|
|
if (IsDefaultSingleValueField)
|
|
newValue = NormalizeDefaultSingleCommitValue(newValue);
|
|
if (ValuesEqual(_valueAtActivation, newValue))
|
|
{
|
|
_draftValue = null;
|
|
_hasDraftValue = false;
|
|
if (_hasPendingChange && Ui?.RevertFieldAsync != null)
|
|
await Ui.RevertFieldAsync(Descriptor.SectionId, Descriptor.OptionName);
|
|
return true;
|
|
}
|
|
var validationError = Descriptor.Validate(newValue);
|
|
if (validationError != null)
|
|
{
|
|
NotificationService.ShowError(validationError);
|
|
return false;
|
|
}
|
|
|
|
var source = Descriptor.GetSource();
|
|
object? valueToCommit = newValue;
|
|
if (newValue is IReadOnlyList<string> list)
|
|
{
|
|
var deduped = list.Distinct(StringComparer.Ordinal).ToList();
|
|
valueToCommit = deduped;
|
|
}
|
|
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, valueToCommit, source?.FilePath);
|
|
if (Ui != null)
|
|
await Ui.CommitFieldAsync(args);
|
|
_draftValue = null;
|
|
_hasDraftValue = false;
|
|
return true;
|
|
}
|
|
|
|
private bool IsDefaultSingleValueField =>
|
|
!Descriptor.IsMultiValue && _customFragment is null;
|
|
|
|
[CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; }
|
|
|
|
private void HandleDefaultValueChanged(string value)
|
|
{
|
|
_draftValue = value;
|
|
_hasDraftValue = true;
|
|
}
|
|
|
|
private async Task HandleDefaultBlur(string? value)
|
|
{
|
|
if (!IsActive) return;
|
|
_draftValue = NormalizeDefaultSingleCommitValue(value);
|
|
_hasDraftValue = true;
|
|
if (!await CommitIfChangedAsync() && Ui != null)
|
|
await Ui.ActivateFieldAsync(FieldKey);
|
|
}
|
|
|
|
private static object? NormalizeDefaultSingleCommitValue(object? value)
|
|
{
|
|
if (value is string s && string.IsNullOrWhiteSpace(s))
|
|
return null;
|
|
return value;
|
|
}
|
|
|
|
private void HandleMultiValueChanged(IReadOnlyList<string> values)
|
|
{
|
|
_draftValue = values.ToList();
|
|
_hasDraftValue = true;
|
|
if (Descriptor.IsMultiValue)
|
|
ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
|
StateHasChanged();
|
|
}
|
|
|
|
private async Task HandleConfirmEdit()
|
|
{
|
|
if (!await CommitIfChangedAsync())
|
|
return;
|
|
_exitAction = ExitAction.Confirm;
|
|
if (Ui != null)
|
|
await Ui.DeactivateFieldAsync();
|
|
}
|
|
|
|
private async Task HandleCancelEdit()
|
|
{
|
|
_draftValue = null;
|
|
_hasDraftValue = false;
|
|
_exitAction = ExitAction.Cancel;
|
|
if (Ui != null)
|
|
await Ui.DeactivateFieldAsync();
|
|
}
|
|
|
|
private async Task HandleRevertEdit()
|
|
{
|
|
_draftValue = null;
|
|
_hasDraftValue = false;
|
|
Session?.RevertChange(Descriptor.SectionId, Descriptor.OptionName);
|
|
_exitAction = ExitAction.Revert;
|
|
if (Ui != null)
|
|
await Ui.DeactivateFieldAsync();
|
|
}
|
|
|
|
private static IReadOnlyList<string>? AsStringList(object? value)
|
|
{
|
|
return value as IReadOnlyList<string>;
|
|
}
|
|
|
|
private static bool ValuesEqual(object? oldValue, object? newValue)
|
|
{
|
|
var oldList = AsStringList(oldValue);
|
|
var newList = AsStringList(newValue);
|
|
if (oldList != null || newList != null)
|
|
return (oldList ?? Array.Empty<string>()).SequenceEqual(newList ?? Array.Empty<string>(), StringComparer.Ordinal);
|
|
return Equals(oldValue, newValue);
|
|
}
|
|
|
|
private enum ExitAction { None, Confirm, Cancel, Revert }
|
|
}
|