mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-04 04:18:13 +10:00
Effective config refactor: session, UI context, cascade cleanup, save modal z-index
- Add IEffectiveConfigEditSession + EffectiveConfigEditSession - Add EffectiveConfigUiContext for option help / readonly popover / commit intents - Extract OptionHelpController, ReadonlyPopoverController, EffectiveConfigSaveFlow, EffectiveConfigToolbar, EffectiveConfigPanels, CopyableCodeLine, OptionHelpLabel, ReadonlyBadge, EffectiveConfigValueDisplayBase - Remove redundant named CascadingValues (IsEditMode, ActiveFieldKey, ManagedFilePath) - Remove unused ManagedFilePath cascading parameter from EffectiveConfigFieldDisplay - Fix save modal stacking: add EffectiveConfigSaveModal.razor.css (dialog above backdrop) - Pending changes displayed before save; hover-close fix for option help and readonly popover
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<div class="d-flex align-items-center gap-1 @CssClass">
|
||||
<code class="flex-grow-1 text-break @CodeCssClass">@Text</code>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyAsync" title="@CopyTitle">Copy</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Text { get; set; } = "";
|
||||
[Parameter] public string? CssClass { get; set; }
|
||||
[Parameter] public string? CodeCssClass { get; set; }
|
||||
[Parameter] public string CopyTitle { get; set; } = "Copy";
|
||||
|
||||
private async Task CopyAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Text)) return;
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("copyToClipboard", Text);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Clipboard not available or failed
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-9
@@ -1,25 +1,21 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* Reusable value-only display for boolean/flag options. When active editor: checkbox (unless readonly); View: "Enabled" / "Disabled". *@
|
||||
@* Reusable value-only display for boolean/flag options. When active editor: checkbox (unless readonly); View: "Enabled" / "Disabled". Pending value shown until save. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
var isSet = Descriptor.GetValue() is bool b && b;
|
||||
var isSet = GetEffectiveDisplayValue() is bool b && b;
|
||||
<input type="checkbox" class="form-check-input" checked="@isSet" @onchange="OnCheckedChange" @onfocusout="OnCommitRequested" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@(Descriptor.GetValue() is bool isSet && isSet ? "Enabled" : "Disabled")
|
||||
@(GetEffectiveDisplayValue() is bool isSet && isSet ? "Enabled" : "Disabled")
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
[CascadingParameter] public bool IsActiveEditor { get; set; }
|
||||
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
|
||||
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
|
||||
|
||||
private async Task OnCheckedChange(ChangeEventArgs e)
|
||||
{
|
||||
await ValueChanged.InvokeAsync(e.Value is true);
|
||||
await NotifyValueChanged(e.Value is true);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-9
@@ -1,16 +1,17 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* Value-only display for int? options. When active editor: number input (unless readonly); View: value + optional suffix/unit. Metadata from EffectiveIntegerConfigFieldDescriptor. *@
|
||||
@* Value-only display for int? options. Pending value shown until save. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
var current = Descriptor.GetValue() is int i ? i : DefaultValue;
|
||||
var current = GetEffectiveDisplayValue() is int i ? i : DefaultValue;
|
||||
<input type="number" class="form-control form-control-sm" style="max-width: 8rem;"
|
||||
min="@Min" max="@Max" value="@(current?.ToString() ?? "")" @oninput="OnInput" @onfocusout="OnCommitRequested" />
|
||||
}
|
||||
else
|
||||
{
|
||||
var v = Descriptor.GetValue() is int n ? n : (int?)null;
|
||||
var v = GetEffectiveDisplayValue() is int n ? n : (int?)null;
|
||||
if (v is null)
|
||||
{
|
||||
@("(not set)")
|
||||
@@ -24,11 +25,6 @@ else
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
[CascadingParameter] public bool IsActiveEditor { get; set; }
|
||||
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
|
||||
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
|
||||
|
||||
private EffectiveIntegerConfigFieldDescriptor? IntDescriptor => Descriptor as EffectiveIntegerConfigFieldDescriptor;
|
||||
private string? ViewSuffix => IntDescriptor?.ViewSuffix;
|
||||
private string? Unit => IntDescriptor?.Unit;
|
||||
@@ -41,6 +37,6 @@ else
|
||||
int? value = null;
|
||||
if (int.TryParse(e.Value?.ToString(), out var num) && num >= Min && num <= Max)
|
||||
value = num;
|
||||
await ValueChanged.InvokeAsync(value);
|
||||
await NotifyValueChanged(value);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-9
@@ -1,10 +1,11 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* local-service: net | host. View: (not set) / net / host. When active editor: dropdown. *@
|
||||
@* local-service: net | host. Pending value shown until save. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
<select class="form-select form-select-sm" style="max-width: 10rem;" value="@(Descriptor.GetValue() as string ?? "")" @onchange="OnChange" @onfocusout="OnCommitRequested">
|
||||
<select class="form-select form-select-sm" style="max-width: 10rem;" value="@(GetEffectiveDisplayValue() as string ?? "")" @onchange="OnChange" @onfocusout="OnCommitRequested">
|
||||
<option value="">(not set)</option>
|
||||
<option value="net">net</option>
|
||||
<option value="host">host</option>
|
||||
@@ -12,20 +13,15 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
var v = Descriptor.GetValue() as string;
|
||||
var v = GetEffectiveDisplayValue() as string;
|
||||
@(string.IsNullOrEmpty(v) ? "(not set)" : (v.Trim().ToLowerInvariant() is "net" or "host" ? v.Trim() : v))
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
[CascadingParameter] public bool IsActiveEditor { get; set; }
|
||||
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
|
||||
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
|
||||
|
||||
private async Task OnChange(ChangeEventArgs e)
|
||||
{
|
||||
var v = e.Value?.ToString()?.Trim();
|
||||
var value = string.IsNullOrEmpty(v) ? null : (v == "net" || v == "host" ? v : null);
|
||||
await ValueChanged.InvokeAsync(value);
|
||||
await NotifyValueChanged(value);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-8
@@ -1,5 +1,6 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* log-queries: optional value extra|proto|auth. View: Off / On / On (extra|proto|auth). When active editor: dropdown. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
@@ -18,14 +19,9 @@ else
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
[CascadingParameter] public bool IsActiveEditor { get; set; }
|
||||
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
|
||||
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
|
||||
|
||||
private string GetEditValue()
|
||||
{
|
||||
var v = Descriptor.GetValue() as string;
|
||||
var v = GetEffectiveDisplayValue() as string;
|
||||
if (string.IsNullOrEmpty(v)) return "";
|
||||
var lower = v.Trim().ToLowerInvariant();
|
||||
if (lower is "extra" or "proto" or "auth") return lower;
|
||||
@@ -34,7 +30,7 @@ else
|
||||
|
||||
private string GetViewText()
|
||||
{
|
||||
var v = Descriptor.GetValue() as string;
|
||||
var v = GetEffectiveDisplayValue() as string;
|
||||
if (string.IsNullOrEmpty(v)) return "Off";
|
||||
var lower = v.Trim().ToLowerInvariant();
|
||||
return lower switch
|
||||
@@ -58,6 +54,6 @@ else
|
||||
"auth" => "auth",
|
||||
_ => null
|
||||
};
|
||||
await ValueChanged.InvokeAsync(newValue);
|
||||
await NotifyValueChanged(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-10
@@ -1,28 +1,24 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* Value-only display for the port option. IsActiveEditor is cascaded from FieldDisplay (true only for the single active field in edit mode). When active editor and not readonly: number input; else: view text. *@
|
||||
@* Value-only display for the port option. Pending value shown until save. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
var port = Descriptor.GetValue() is int p ? p : 53;
|
||||
var port = GetEffectiveDisplayValue() is int p ? p : 53;
|
||||
<input type="number" class="form-control form-control-sm" style="max-width: 6rem;" min="1" max="65535" value="@port" @oninput="OnPortInput" @onfocusout="OnCommitRequested" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@(Descriptor.GetValue() is int port ? $"{port} (default DNS port)" : "(not set)")
|
||||
@(GetEffectiveDisplayValue() is int port ? $"{port} (default DNS port)" : "(not set)")
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
[CascadingParameter] public bool IsActiveEditor { get; set; }
|
||||
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
|
||||
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
|
||||
|
||||
private async Task OnPortInput(ChangeEventArgs e)
|
||||
{
|
||||
if (int.TryParse(e.Value?.ToString(), out var port) && port >= 1 && port <= 65535)
|
||||
await ValueChanged.InvokeAsync(port);
|
||||
await NotifyValueChanged(port);
|
||||
else
|
||||
await ValueChanged.InvokeAsync(null);
|
||||
await NotifyValueChanged(null);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-41
@@ -6,7 +6,7 @@
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
|
||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
||||
|
||||
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. *@
|
||||
@* 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);
|
||||
@@ -14,10 +14,10 @@
|
||||
var showEditableBadge = !isReadonly;
|
||||
var isActiveEditor = IsEditMode && IsActive;
|
||||
var fragmentWithCallback = isActiveEditor ? _fragmentWithCallback : null;
|
||||
var effectiveValue = _effectiveValue ?? Descriptor.GetValue();
|
||||
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
||||
<span id="@_anchorId" class="ec-field-label @(_optionHelpKey != null ? "ec-field-label-help" : null)" title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Descriptor.OptionName:</strong></span>
|
||||
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" CssClass="@(_optionHelpKey != null ? "ec-field-label ec-field-label-help" : "ec-field-label")" Title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
|
||||
<span class="ec-field-value">
|
||||
@* IsActiveEditor: cascaded from here (true only for this field when it's the active editor). Section cascades IsEditMode + ActiveFieldKey; Panel passes IsActive=(ActiveFieldKey==fieldKey); we derive isActiveEditor = IsEditMode && IsActive and cascade that bool so fragment children (e.g. PortValueDisplay) know whether to show the input. *@
|
||||
@if (isActiveEditor)
|
||||
{
|
||||
<div class="ec-field-edit-wrapper" @onfocusout="OnBlur" @onclick:stopPropagation="true">
|
||||
@@ -34,20 +34,10 @@
|
||||
@fragment(Descriptor)
|
||||
</CascadingValue>
|
||||
}
|
||||
@if (isReadonly)
|
||||
{
|
||||
@if (IsEditMode)
|
||||
{
|
||||
<button type="button" id="@_readonlyBadgeAnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())" @onclick="RaiseReadonlyBadgeClicked" @onmouseleave="OnReadonlyBadgeMouseLeave">readonly</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary ec-badge-inline ms-1" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">readonly</span>
|
||||
}
|
||||
}
|
||||
<ReadonlyBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" />
|
||||
@if (showEditableBadge)
|
||||
{
|
||||
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
|
||||
<button type="button" class="badge ec-badge-inline border-0 ms-1 @(_hasPendingChange ? "bg-warning text-dark" : "bg-primary")" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="@(_hasPendingChange ? "Pending change" : "Edit this field")" aria-label="Edit">@(_hasPendingChange ? "pending" : "editable")</button>
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
@@ -55,11 +45,11 @@
|
||||
else if (Descriptor.IsMultiValue)
|
||||
{
|
||||
var items = Descriptor.GetItems();
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId" OnReadonlyBadgeClicked="@OnReadonlyBadgeClicked" OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave" />
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId" />
|
||||
}
|
||||
else
|
||||
{
|
||||
var value = Descriptor.GetValue();
|
||||
var value = _effectiveValue ?? Descriptor.GetValue();
|
||||
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
|
||||
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
|
||||
var showReadonlyHint = IsEditMode && isReadonly;
|
||||
@@ -67,16 +57,18 @@ else
|
||||
@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" OnOptionHelpRequested="@OnOptionHelpRequested" SuppressOuterLi="true"
|
||||
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" OnReadonlyBadgeClick="@RaiseReadonlyBadgeClicked" OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave" />
|
||||
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange" />
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested"
|
||||
<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"
|
||||
ShowEditableBadge="true" OnActivateEdit="@OnActivateEdit" IsActive="@IsActive"
|
||||
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" OnReadonlyBadgeClick="@RaiseReadonlyBadgeClicked" OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave"
|
||||
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange"
|
||||
DisplayValueChanged="@HandleDefaultValueChanged" OnBlur="@HandleDefaultBlur" />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +79,7 @@ else
|
||||
[Parameter] public string? ActiveFieldKey { get; set; }
|
||||
[Parameter] public EventCallback OnActivateEdit { get; set; }
|
||||
[Parameter] public EventCallback<EffectiveConfigEditCommittedArgs> OnEditCommitted { get; set; }
|
||||
[Parameter] public EventCallback<ReadonlyBadgeClickedEventArgs> OnReadonlyBadgeClicked { get; set; }
|
||||
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
|
||||
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
|
||||
[CascadingParameter] public string? ManagedFilePath { 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];
|
||||
@@ -98,6 +87,8 @@ else
|
||||
private RenderFragment<EffectiveConfigFieldDescriptor>? _fragmentWithCallback;
|
||||
private string? _labelTooltip;
|
||||
private string? _optionHelpKey;
|
||||
private object? _effectiveValue;
|
||||
private bool _hasPendingChange;
|
||||
private object? _valueAtActivation;
|
||||
private object? _draftValue;
|
||||
private bool _wasActive;
|
||||
@@ -108,8 +99,13 @@ else
|
||||
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
|
||||
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
||||
_optionHelpKey = DnsmasqOptionTooltips.GetOptionHelpKey(Descriptor.OptionName);
|
||||
var pending = Session?.PendingChanges.FirstOrDefault(c =>
|
||||
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||
_hasPendingChange = pending != null;
|
||||
_effectiveValue = pending?.NewValue ?? Descriptor.GetValue();
|
||||
if (!_wasActive && IsActive)
|
||||
_valueAtActivation = Descriptor.GetValue();
|
||||
_valueAtActivation = _effectiveValue;
|
||||
if (_wasActive && !IsActive)
|
||||
_ = InvokeAsync(CommitIfChangedAsync);
|
||||
_wasActive = IsActive;
|
||||
@@ -128,21 +124,20 @@ else
|
||||
|
||||
private async Task CommitIfChangedAsync()
|
||||
{
|
||||
var newValue = _draftValue ?? Descriptor.GetValue();
|
||||
var newValue = _draftValue ?? _effectiveValue ?? Descriptor.GetValue();
|
||||
if (!Equals(_valueAtActivation, newValue))
|
||||
{
|
||||
var source = Descriptor.GetSource();
|
||||
await OnEditCommitted.InvokeAsync(new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath));
|
||||
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath);
|
||||
if (Ui != null)
|
||||
await Ui.CommitFieldAsync(args);
|
||||
else if (OnEditCommitted.HasDelegate)
|
||||
await OnEditCommitted.InvokeAsync(args);
|
||||
}
|
||||
_draftValue = null;
|
||||
}
|
||||
|
||||
private async Task RaiseReadonlyBadgeClicked()
|
||||
{
|
||||
var source = Descriptor.GetSource();
|
||||
if (source == null) return;
|
||||
await OnReadonlyBadgeClicked.InvokeAsync(new ReadonlyBadgeClickedEventArgs(_readonlyBadgeAnchorId, source, Descriptor.OptionName, Descriptor.GetValue()));
|
||||
}
|
||||
[CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; }
|
||||
|
||||
private void HandleDefaultValueChanged(string value)
|
||||
{
|
||||
@@ -156,11 +151,4 @@ else
|
||||
await CommitIfChangedAsync();
|
||||
}
|
||||
|
||||
private async Task RaiseOptionHelpRequested()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_optionHelpKey)) return;
|
||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = _optionHelpKey, OptionLabel = Descriptor.OptionName, AnchorId = _anchorId });
|
||||
}
|
||||
|
||||
private async Task RaiseLabelMouseLeave() => await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = _anchorId, IsLabelMouseLeave = true });
|
||||
}
|
||||
|
||||
+3
-14
@@ -4,18 +4,8 @@
|
||||
|
||||
<span class="ec-field-value-item" title="@(Source?.GetReadOnlyTooltip())">
|
||||
@Value
|
||||
@if (Source?.IsReadOnly == true)
|
||||
{
|
||||
@if (IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId) && OnReadonlyBadgeClick.HasDelegate)
|
||||
{
|
||||
<button type="button" id="@ReadonlyBadgeAnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Source?.GetReadOnlyTooltip())" @onclick="OnReadonlyBadgeClick" @onmouseleave="OnReadonlyBadgeMouseLeave">readonly</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary ec-badge-inline ms-1">readonly</span>
|
||||
}
|
||||
}
|
||||
else if (IsEditMode)
|
||||
<ReadonlyBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value" IsInteractive="@(IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" />
|
||||
@if (Source?.IsReadOnly != true && IsEditMode)
|
||||
{
|
||||
<span class="badge bg-primary ec-badge-inline ms-1">editable</span>
|
||||
}
|
||||
@@ -24,8 +14,7 @@
|
||||
@code {
|
||||
[Parameter] public string Value { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
|
||||
[Parameter] public EventCallback OnReadonlyBadgeClick { get; set; }
|
||||
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
|
||||
}
|
||||
|
||||
+3
-19
@@ -3,7 +3,7 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<li class="ec-field">
|
||||
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@LabelTooltip" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
|
||||
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" CssClass="@(OptionHelpKey != null ? "ec-field-label ec-field-label-help" : "ec-field-label")" Title="@LabelTooltip"><strong>@Label:</strong></OptionHelpLabel>
|
||||
@if (IsActiveEditor)
|
||||
{
|
||||
<span class="ec-field-value ec-field-value-multi" @onclick:stopPropagation="true">
|
||||
@@ -18,9 +18,8 @@
|
||||
<ol class="ec-field-value-list">
|
||||
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
|
||||
{
|
||||
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : null;
|
||||
var args = new ReadonlyBadgeClickedEventArgs(anchorId ?? "", item.Source!, Label, item.Value);
|
||||
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorId="@anchorId" OnReadonlyBadgeClick="@(() => _ = OnReadonlyBadgeClicked.InvokeAsync(args))" OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave" /></li>
|
||||
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : "";
|
||||
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" OptionName="@Label" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorId="@anchorId" /></li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
@@ -42,24 +41,9 @@
|
||||
[Parameter] public bool IsActiveEditor { get; set; }
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ReadonlyBadgeAnchorIdBase { get; set; }
|
||||
[Parameter] public EventCallback<ReadonlyBadgeClickedEventArgs> OnReadonlyBadgeClicked { get; set; }
|
||||
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
|
||||
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
|
||||
|
||||
private string _editLines = "";
|
||||
|
||||
private async Task RaiseOptionHelpRequested()
|
||||
{
|
||||
if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return;
|
||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId });
|
||||
}
|
||||
|
||||
private async Task RaiseLabelMouseLeave()
|
||||
{
|
||||
if (string.IsNullOrEmpty(OptionHelpAnchorId)) return;
|
||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Items != null && Items.Count > 0)
|
||||
|
||||
+9
-72
@@ -2,16 +2,14 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
@if (SuppressOuterLi)
|
||||
{
|
||||
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
|
||||
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" CssClass="@(OptionHelpKey != null ? "ec-field-label ec-field-label-help" : "ec-field-label")" Title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())"><strong>@Label:</strong></OptionHelpLabel>
|
||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true)
|
||||
{
|
||||
<span class="ec-field-value" @onclick:stopPropagation="true">
|
||||
<input type="text" class="form-control form-control-sm" value="@(_editValue ?? DisplayValue)" @oninput="OnValueInput" @onfocusout="OnInputBlur" />
|
||||
@if (ShowEditableBadge)
|
||||
{
|
||||
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
|
||||
<button type="button" class="badge ec-badge-inline border-0 ms-1 @(HasPendingChange ? "bg-warning text-dark" : "bg-primary")" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="@(HasPendingChange ? "Pending change" : "Edit this field")" aria-label="Edit">@(HasPendingChange ? "pending" : "editable")</button>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
@@ -19,78 +17,30 @@
|
||||
{
|
||||
<span class="ec-field-value">
|
||||
@DisplayValue
|
||||
@if (Source?.IsReadOnly == true)
|
||||
{
|
||||
@if (!string.IsNullOrEmpty(ReadonlyBadgeAnchorId) && OnReadonlyBadgeClick.HasDelegate)
|
||||
{
|
||||
<button type="button" id="@ReadonlyBadgeAnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Source?.GetReadOnlyTooltip())" @onclick="OnReadonlyBadgeClick" @onmouseleave="OnReadonlyBadgeMouseLeave">readonly</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary ec-badge-inline ms-1" title="@(Source?.GetReadOnlyTooltip())">readonly</span>
|
||||
}
|
||||
}
|
||||
<ReadonlyBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@Label" Value="@(RawValue ?? DisplayValue)" IsInteractive="@(DisplayMode == EffectiveConfigDisplayMode.Edit && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" />
|
||||
@if (ShowEditableBadge && Source?.IsReadOnly != true)
|
||||
{
|
||||
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
|
||||
<button type="button" class="badge ec-badge-inline border-0 ms-1 @(HasPendingChange ? "bg-warning text-dark" : "bg-primary")" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="@(HasPendingChange ? "Pending change" : "Edit this field")" aria-label="Edit">@(HasPendingChange ? "pending" : "editable")</button>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="ec-field @(IsActive ? "ec-field-editing" : "")" title="@(Source?.GetReadOnlyTooltip())">
|
||||
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
|
||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true)
|
||||
{
|
||||
<span class="ec-field-value" @onclick:stopPropagation="true">
|
||||
<input type="text" class="form-control form-control-sm" value="@(_editValue ?? DisplayValue)" @oninput="OnValueInput" @onfocusout="OnInputBlur" />
|
||||
@if (ShowEditableBadge)
|
||||
{
|
||||
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ec-field-value">
|
||||
@DisplayValue
|
||||
@if (Source?.IsReadOnly == true)
|
||||
{
|
||||
@if (!string.IsNullOrEmpty(ReadonlyBadgeAnchorId) && OnReadonlyBadgeClick.HasDelegate)
|
||||
{
|
||||
<button type="button" id="@ReadonlyBadgeAnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Source?.GetReadOnlyTooltip())" @onclick="OnReadonlyBadgeClick" @onmouseleave="OnReadonlyBadgeMouseLeave">readonly</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary ec-badge-inline ms-1" title="@(Source?.GetReadOnlyTooltip())">readonly</span>
|
||||
}
|
||||
}
|
||||
@if (ShowEditableBadge && Source?.IsReadOnly != true)
|
||||
{
|
||||
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
[Parameter] public string DisplayValue { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
/// <summary>When true, render only the inner content (no outer li); used when parent wraps with ReadonlyEditHint.</summary>
|
||||
[Parameter] public bool SuppressOuterLi { get; set; }
|
||||
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
|
||||
[Parameter] public string? LabelTooltip { get; set; }
|
||||
[Parameter] public string? OptionHelpKey { get; set; }
|
||||
[Parameter] public string? OptionHelpAnchorId { get; set; }
|
||||
/// <summary>When set with OnReadonlyBadgeClick, the readonly badge is rendered as a button with this id so the parent can open a popover under it.</summary>
|
||||
/// <summary>When set in edit mode, the readonly badge is rendered as a button with this id so the popover can anchor to it.</summary>
|
||||
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
|
||||
[Parameter] public EventCallback OnReadonlyBadgeClick { get; set; }
|
||||
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
|
||||
/// <summary>Raw value for the readonly popover override line (e.g. from descriptor); falls back to DisplayValue.</summary>
|
||||
[Parameter] public object? RawValue { get; set; }
|
||||
/// <summary>When true, show an 'editable' badge next to non-readonly fields; clicking it activates edit for this field.</summary>
|
||||
[Parameter] public bool ShowEditableBadge { get; set; }
|
||||
/// <summary>When true, badge shows "pending" and warning style to indicate this field has an unsaved change.</summary>
|
||||
[Parameter] public bool HasPendingChange { get; set; }
|
||||
[Parameter] public EventCallback OnActivateEdit { get; set; }
|
||||
/// <summary>When true, add ec-field-editing class and show input when DisplayMode is Edit.</summary>
|
||||
[Parameter] public bool IsActive { get; set; }
|
||||
@@ -99,19 +49,6 @@ else
|
||||
/// <summary>When Edit and not readonly: raised when the input loses focus; argument is the current value.</summary>
|
||||
[Parameter] public EventCallback<string?> OnBlur { get; set; }
|
||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
|
||||
|
||||
private async Task RaiseOptionHelpRequested()
|
||||
{
|
||||
if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return;
|
||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId });
|
||||
}
|
||||
|
||||
private async Task RaiseLabelMouseLeave()
|
||||
{
|
||||
if (string.IsNullOrEmpty(OptionHelpAnchorId)) return;
|
||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
|
||||
}
|
||||
|
||||
private string? _editValue;
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
||||
|
||||
<div class="ec-sections small">
|
||||
@{
|
||||
var views = EffectiveConfigViews.GetViewsForContext(Context);
|
||||
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry);
|
||||
var hasSearch = !string.IsNullOrWhiteSpace(SearchTerm);
|
||||
var term = SearchTerm?.Trim() ?? "";
|
||||
}
|
||||
@foreach (var view in views)
|
||||
{
|
||||
if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors))
|
||||
continue;
|
||||
var sectionDescriptors = hasSearch
|
||||
? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList()
|
||||
: contextDescriptors.ToList();
|
||||
if (sectionDescriptors.Count == 0)
|
||||
continue;
|
||||
var isOpen = hasSearch || IsSectionOpen(view.SectionId);
|
||||
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle"
|
||||
DisplayMode="@( IsEditMode ? EffectiveConfigDisplayMode.Edit : EffectiveConfigDisplayMode.View )"
|
||||
IsEditMode="@IsEditMode" ActiveFieldKey="@ActiveFieldKey"
|
||||
OnActivateField="@OnActivateField" OnFieldEditCommitted="@OnFieldEditCommitted" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
|
||||
[Parameter] public string SearchTerm { get; set; } = "";
|
||||
[Parameter] public HashSet<string> OpenSectionIds { get; set; } = new();
|
||||
[Parameter] public EventCallback<string> OnSectionToggle { get; set; }
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ActiveFieldKey { get; set; }
|
||||
[Parameter] public EventCallback<string> OnActivateField { get; set; }
|
||||
[Parameter] public EventCallback<EffectiveConfigEditCommittedArgs> OnFieldEditCommitted { get; set; }
|
||||
|
||||
private bool IsSectionOpen(string id) => OpenSectionIds.Contains(id);
|
||||
|
||||
private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term)
|
||||
{
|
||||
if (string.IsNullOrEmpty(term)) return true;
|
||||
var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim();
|
||||
return searchable.Contains(term, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
|
||||
|
||||
@if (ShowModal)
|
||||
{
|
||||
<EffectiveConfigSaveModal PendingChanges="@PendingChanges" Status="@Status" OnClose="@OnClose" OnSave="@HandleSave" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool ShowModal { get; set; }
|
||||
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
[Parameter] public EventCallback OnSaveCompleted { get; set; }
|
||||
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
|
||||
|
||||
private async Task HandleSave()
|
||||
{
|
||||
if (Session.PendingChanges.Count == 0) return;
|
||||
try
|
||||
{
|
||||
await Session.ApplyAsync();
|
||||
await OnClose.InvokeAsync();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// TODO: show error to user (e.g. toast or modal message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Ensure the dialog stacks above the backdrop when both are siblings inside .modal */
|
||||
.modal-dialog {
|
||||
position: relative;
|
||||
z-index: 1056;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
z-index: 1050;
|
||||
}
|
||||
@@ -3,88 +3,26 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
|
||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
||||
@inject DnsmasqWebUI.Infrastructure.Services.Abstractions.IDnsmasqConfigService ConfigService
|
||||
@inject IEffectiveConfigEditSession Session
|
||||
@inject IJSRuntime JSRuntime
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (Status != null)
|
||||
{
|
||||
<div class="ec-container" @onclick="DeactivateActiveFieldIfClicked">
|
||||
<ConfigOptionHelpModal HelpKey="@_optionHelpKey" AnchorId="@_optionHelpAnchorId" Visible="@_showOptionHelp" Title="@_optionHelpTitle"
|
||||
OnClose="CloseOptionHelp" OnMouseEnterModal="CancelCloseTimerAsync" OnMouseLeaveModal="ScheduleCloseAsync" />
|
||||
<ReadonlyEditPopover Visible="@_showReadonlyPopover" AnchorId="@_readonlyPopoverAnchorId" Source="@_readonlyPopoverSource" OptionName="@(_readonlyPopoverOptionName ?? "")" Value="@_readonlyPopoverValue" ManagedFilePath="@(Status?.ManagedFilePath)"
|
||||
OnClose="CloseReadonlyPopover" OnMouseEnterPopover="CancelReadonlyCloseTimerAsync" OnMouseLeavePopover="ScheduleReadonlyCloseAsync" />
|
||||
<div class="ec-toolbar @(_isEditMode ? "ec-edit-toolbar" : "")">
|
||||
@if (ShowSearchBox)
|
||||
{
|
||||
<input type="search" id="ec-search-input" class="form-control form-control-sm ec-toolbar-search" placeholder="Search..." autocomplete="off"
|
||||
@bind="_searchTerm" @bind:event="oninput" @bind:after="StateHasChanged" />
|
||||
}
|
||||
<div class="ec-toolbar-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ExpandAll" title="Expand all sections" aria-label="Expand all sections">
|
||||
<i class="bi bi-arrows-expand" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CollapseAll" title="Collapse all sections" aria-label="Collapse all sections">
|
||||
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
|
||||
</button>
|
||||
@if (_isEditMode)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="OpenSaveModal" disabled="@(_pendingChanges.Count == 0)" title="Save changes to managed config" aria-label="Save">
|
||||
<i class="bi bi-floppy-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
@if (_pendingChanges.Count > 0)
|
||||
{
|
||||
<span class="text-muted small">@_pendingChanges.Count change(s)</span>
|
||||
}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ExitEditModeWithConfirmAsync" title="Exit edit mode" aria-label="Exit edit mode">
|
||||
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="EnterEditMode" title="Edit effective config" aria-label="Edit">
|
||||
<i class="bi bi-pencil" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<CascadingValue Name="IsEditMode" Value="@_isEditMode">
|
||||
<CascadingValue Name="ActiveFieldKey" Value="@_activeFieldKey">
|
||||
<CascadingValue Name="ManagedFilePath" Value="@(Status?.ManagedFilePath)">
|
||||
<div class="ec-sections small">
|
||||
@{
|
||||
var views = EffectiveConfigViews.GetViewsForContext(Context);
|
||||
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry);
|
||||
var hasSearch = !string.IsNullOrWhiteSpace(_searchTerm);
|
||||
var term = _searchTerm?.Trim() ?? "";
|
||||
}
|
||||
@foreach (var view in views)
|
||||
{
|
||||
if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors))
|
||||
continue;
|
||||
var sectionDescriptors = hasSearch
|
||||
? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList()
|
||||
: contextDescriptors.ToList();
|
||||
if (sectionDescriptors.Count == 0)
|
||||
continue;
|
||||
var isOpen = hasSearch ? true : IsOpen(view.SectionId);
|
||||
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle"
|
||||
DisplayMode="@( _isEditMode ? EffectiveConfigDisplayMode.Edit : EffectiveConfigDisplayMode.View )"
|
||||
IsEditMode="@_isEditMode" ActiveFieldKey="@_activeFieldKey"
|
||||
OnActivateField="ActivateField" OnFieldEditCommitted="HandleFieldEditCommitted"
|
||||
OnReadonlyBadgeClicked="HandleReadonlyBadgeClicked"
|
||||
OnReadonlyBadgeMouseLeave="ScheduleReadonlyCloseAsync"
|
||||
OnOptionHelpRequested="@HandleOptionHelpRequested" />
|
||||
}
|
||||
</div>
|
||||
<OptionHelpController @ref="_optionHelpControllerRef" Visible="@_showOptionHelp" HelpKey="@_optionHelpKey" AnchorId="@_optionHelpAnchorId" Title="@_optionHelpTitle" OnCloseRequested="CloseOptionHelp" />
|
||||
<ReadonlyPopoverController @ref="_readonlyPopoverControllerRef" Visible="@_showReadonlyPopover" AnchorId="@_readonlyPopoverAnchorId" Source="@_readonlyPopoverSource" OptionName="@(_readonlyPopoverOptionName ?? "")" Value="@_readonlyPopoverValue" ManagedFilePath="@(Status?.ManagedFilePath)" OnCloseRequested="CloseReadonlyPopover" />
|
||||
<EffectiveConfigToolbar ShowSearchBox="@ShowSearchBox" SearchTerm="@_searchTerm" SearchTermChanged="@OnSearchTermChanged"
|
||||
IsEditMode="@Session.IsEditMode" PendingChangesCount="@Session.PendingChanges.Count"
|
||||
OnExpandAll="ExpandAll" OnCollapseAll="CollapseAll" OnEnterEditMode="EnterEditMode" OnExitEditModeWithConfirm="ExitEditModeWithConfirmAsync" OnOpenSaveModal="OpenSaveModal" />
|
||||
<CascadingValue Value="@Session">
|
||||
<CascadingValue Value="@_uiContext">
|
||||
<EffectiveConfigPanels Status="@Status" Context="@Context" SearchTerm="@_searchTerm" OpenSectionIds="@_openPanels"
|
||||
OnSectionToggle="OnSectionToggle" IsEditMode="@Session.IsEditMode" ActiveFieldKey="@Session.ActiveFieldKey"
|
||||
OnActivateField="ActivateField" OnFieldEditCommitted="HandleFieldEditCommitted" />
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
@if (_showSaveModal)
|
||||
{
|
||||
<EffectiveConfigSaveModal PendingChanges="_pendingChanges" Status="@Status" OnClose="CloseSaveModal" OnSave="ConfirmSave" />
|
||||
}
|
||||
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.PendingChanges" Status="@Status" Session="@Session"
|
||||
OnClose="CloseSaveModal" OnSaveCompleted="OnSaveCompleted" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -92,139 +30,95 @@
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
|
||||
[Parameter] public bool ShowSearchBox { get; set; } = true;
|
||||
/// <summary>Raised after pending changes were successfully written to managed config (so the page can refresh status).</summary>
|
||||
[Parameter] public EventCallback OnSaveCompleted { get; set; }
|
||||
|
||||
private string _searchTerm = "";
|
||||
private HashSet<string> _openPanels = new();
|
||||
private bool _showSaveModal;
|
||||
|
||||
private string? _optionHelpKey;
|
||||
private string? _optionHelpAnchorId;
|
||||
private string _optionHelpTitle = "Option help";
|
||||
private bool _showOptionHelp;
|
||||
private CancellationTokenSource? _closeCts;
|
||||
|
||||
private bool _isEditMode;
|
||||
private string? _activeFieldKey;
|
||||
private readonly List<PendingEffectiveConfigChange> _pendingChanges = new();
|
||||
private bool _showSaveModal;
|
||||
private OptionHelpController? _optionHelpControllerRef;
|
||||
|
||||
private bool _showReadonlyPopover;
|
||||
private ReadonlyPopoverController? _readonlyPopoverControllerRef;
|
||||
private string? _readonlyPopoverAnchorId;
|
||||
private ConfigValueSource? _readonlyPopoverSource;
|
||||
private string? _readonlyPopoverOptionName;
|
||||
private object? _readonlyPopoverValue;
|
||||
private CancellationTokenSource? _readonlyCloseCts;
|
||||
|
||||
private bool IsOpen(string id) => _openPanels.Contains(id);
|
||||
private EffectiveConfigUiContext? _uiContext;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_uiContext = new EffectiveConfigUiContext
|
||||
{
|
||||
RequestOptionHelpAsync = HandleOptionHelpRequested,
|
||||
RequestReadonlyPopoverAsync = HandleReadonlyBadgeClicked,
|
||||
ScheduleReadonlyPopoverCloseAsync = () =>
|
||||
{
|
||||
_readonlyPopoverControllerRef?.ScheduleCloseFromBadgeLeave();
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; },
|
||||
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; }
|
||||
};
|
||||
}
|
||||
|
||||
private async Task OnSearchTermChanged(string value)
|
||||
{
|
||||
_searchTerm = value ?? "";
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void CloseOptionHelp()
|
||||
{
|
||||
_showOptionHelp = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleOptionHelpRequested(ConfigOptionHelpRequestEventArgs args)
|
||||
{
|
||||
if (args.IsLabelMouseLeave)
|
||||
{
|
||||
if (args.AnchorId == _optionHelpAnchorId)
|
||||
ScheduleClose();
|
||||
_optionHelpControllerRef?.ScheduleCloseFromLabelLeave();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
_optionHelpKey = args.HelpKey;
|
||||
_optionHelpTitle = args.OptionLabel;
|
||||
_optionHelpAnchorId = args.AnchorId;
|
||||
_showOptionHelp = true;
|
||||
CancelCloseTimer();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void CancelCloseTimer()
|
||||
private void CloseReadonlyPopover()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = null;
|
||||
}
|
||||
|
||||
private Task CancelCloseTimerAsync() { CancelCloseTimer(); return Task.CompletedTask; }
|
||||
|
||||
private void ScheduleClose()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = new CancellationTokenSource();
|
||||
var token = _closeCts.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(300, token);
|
||||
}
|
||||
catch (OperationCanceledException) { return; }
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
_showOptionHelp = false;
|
||||
_closeCts = null;
|
||||
StateHasChanged();
|
||||
});
|
||||
}, token);
|
||||
}
|
||||
|
||||
private Task ScheduleCloseAsync() { ScheduleClose(); return Task.CompletedTask; }
|
||||
|
||||
private void CloseOptionHelp()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
_showOptionHelp = false;
|
||||
_showReadonlyPopover = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleReadonlyBadgeClicked(ReadonlyBadgeClickedEventArgs args)
|
||||
{
|
||||
_readonlyPopoverControllerRef?.CancelCloseFromBadgeEnter();
|
||||
_readonlyPopoverAnchorId = args.AnchorId;
|
||||
_readonlyPopoverSource = args.Source;
|
||||
_readonlyPopoverOptionName = args.OptionName;
|
||||
_readonlyPopoverValue = args.Value;
|
||||
_showReadonlyPopover = true;
|
||||
CancelReadonlyCloseTimer();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void CancelReadonlyCloseTimer()
|
||||
{
|
||||
_readonlyCloseCts?.Cancel();
|
||||
_readonlyCloseCts = null;
|
||||
}
|
||||
|
||||
private Task CancelReadonlyCloseTimerAsync() { CancelReadonlyCloseTimer(); return Task.CompletedTask; }
|
||||
|
||||
private void ScheduleReadonlyClose()
|
||||
{
|
||||
_readonlyCloseCts?.Cancel();
|
||||
_readonlyCloseCts = new CancellationTokenSource();
|
||||
var token = _readonlyCloseCts.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await Task.Delay(300, token); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
_showReadonlyPopover = false;
|
||||
_readonlyCloseCts = null;
|
||||
StateHasChanged();
|
||||
});
|
||||
}, token);
|
||||
}
|
||||
|
||||
private Task ScheduleReadonlyCloseAsync() { ScheduleReadonlyClose(); return Task.CompletedTask; }
|
||||
|
||||
private void CloseReadonlyPopover()
|
||||
{
|
||||
CancelReadonlyCloseTimer();
|
||||
_showReadonlyPopover = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnSectionToggle(string id)
|
||||
{
|
||||
if (_openPanels.Contains(id))
|
||||
_openPanels.Remove(id);
|
||||
else
|
||||
_openPanels.Add(id);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ExpandAll()
|
||||
@@ -240,24 +134,15 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term)
|
||||
{
|
||||
if (string.IsNullOrEmpty(term)) return true;
|
||||
var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim();
|
||||
return searchable.Contains(term, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void EnterEditMode()
|
||||
{
|
||||
_isEditMode = true;
|
||||
_activeFieldKey = null;
|
||||
_pendingChanges.Clear();
|
||||
Session.EnterEditMode();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ExitEditModeWithConfirmAsync()
|
||||
{
|
||||
if (_pendingChanges.Count > 0)
|
||||
if (Session.PendingChanges.Count > 0)
|
||||
{
|
||||
var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
|
||||
try
|
||||
@@ -270,42 +155,34 @@
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
_pendingChanges.Clear();
|
||||
_activeFieldKey = null;
|
||||
_isEditMode = false;
|
||||
Session.ExitEditModeDiscard();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void DeactivateActiveFieldIfClicked()
|
||||
{
|
||||
if (_isEditMode && _activeFieldKey != null)
|
||||
if (Session.IsEditMode && Session.ActiveFieldKey != null)
|
||||
{
|
||||
_activeFieldKey = null;
|
||||
Session.DeactivateField();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void ActivateField(string fieldKey)
|
||||
{
|
||||
_isEditMode = true;
|
||||
_activeFieldKey = fieldKey;
|
||||
Session.ActivateField(fieldKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args)
|
||||
{
|
||||
var existing = _pendingChanges.FirstOrDefault(c => c.SectionId == args.SectionId && c.OptionName == args.OptionName);
|
||||
if (existing != null)
|
||||
_pendingChanges.Remove(existing);
|
||||
if (!Equals(args.OldValue, args.NewValue))
|
||||
_pendingChanges.Add(new PendingEffectiveConfigChange(args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath));
|
||||
_activeFieldKey = null;
|
||||
Session.TrackCommit(args);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OpenSaveModal()
|
||||
{
|
||||
if (_pendingChanges.Count == 0) return;
|
||||
if (Session.PendingChanges.Count == 0) return;
|
||||
_showSaveModal = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
@@ -315,33 +192,4 @@
|
||||
_showSaveModal = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ConfirmSave()
|
||||
{
|
||||
if (_pendingChanges.Count == 0) return;
|
||||
var changes = _pendingChanges.ToList();
|
||||
try
|
||||
{
|
||||
await ConfigService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
CloseSaveModal();
|
||||
_pendingChanges.Clear();
|
||||
_activeFieldKey = null;
|
||||
_isEditMode = false;
|
||||
StateHasChanged();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// TODO: show error to user (e.g. toast or modal message)
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts?.Dispose();
|
||||
_closeCts = null;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -13,7 +13,8 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ec-toolbar {
|
||||
/* Toolbar: single row, search left and actions right. Use ::deep so styles apply to EffectiveConfigToolbar (child) markup. */
|
||||
::deep .ec-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
@@ -22,14 +23,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Search uses available space; actions stay on the right */
|
||||
.ec-toolbar-search {
|
||||
::deep .ec-toolbar-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.ec-toolbar-actions {
|
||||
::deep .ec-toolbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
+1
-8
@@ -19,10 +19,7 @@
|
||||
IsActive="@(ActiveFieldKey == fieldKey)"
|
||||
ActiveFieldKey="@ActiveFieldKey"
|
||||
OnActivateEdit="@(() => OnActivateField.InvokeAsync(fieldKey))"
|
||||
OnEditCommitted="@OnFieldEditCommitted"
|
||||
OnReadonlyBadgeClicked="@OnReadonlyBadgeClicked"
|
||||
OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave"
|
||||
OnOptionHelpRequested="@OnOptionHelpRequested" />
|
||||
OnEditCommitted="@OnFieldEditCommitted" />
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -36,16 +33,12 @@
|
||||
[Parameter] public IReadOnlyList<EffectiveConfigFieldDescriptor> Descriptors { get; set; } = null!;
|
||||
[Parameter] public bool IsOpen { get; set; }
|
||||
[Parameter] public EventCallback<string> OnToggle { get; set; }
|
||||
/// <summary>Raised when user requests option help (click or mouse leave from label). Passed through from Section.</summary>
|
||||
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
|
||||
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable.</summary>
|
||||
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ActiveFieldKey { get; set; }
|
||||
[Parameter] public EventCallback<string> OnActivateField { get; set; }
|
||||
[Parameter] public EventCallback<EffectiveConfigEditCommittedArgs> OnFieldEditCommitted { get; set; }
|
||||
[Parameter] public EventCallback<ReadonlyBadgeClickedEventArgs> OnReadonlyBadgeClicked { get; set; }
|
||||
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
|
||||
|
||||
private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
|
||||
<div class="ec-toolbar @(IsEditMode ? "ec-edit-toolbar" : "")">
|
||||
@if (ShowSearchBox)
|
||||
{
|
||||
<input type="search" id="ec-search-input" class="form-control form-control-sm ec-toolbar-search" placeholder="Search..." autocomplete="off"
|
||||
value="@SearchTerm" @oninput="OnSearchInput" />
|
||||
}
|
||||
<div class="ec-toolbar-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnExpandAll" title="Expand all sections" aria-label="Expand all sections">
|
||||
<i class="bi bi-arrows-expand" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCollapseAll" title="Collapse all sections" aria-label="Collapse all sections">
|
||||
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
|
||||
</button>
|
||||
@if (IsEditMode)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="OnOpenSaveModal" disabled="@(PendingChangesCount == 0)" title="Save changes to managed config" aria-label="Save">
|
||||
<i class="bi bi-floppy-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
@if (PendingChangesCount > 0)
|
||||
{
|
||||
<span class="text-muted small">@PendingChangesCount change(s)</span>
|
||||
}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnExitEditModeWithConfirm" title="Exit edit mode" aria-label="Exit edit mode">
|
||||
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnEnterEditMode" title="Edit effective config" aria-label="Edit">
|
||||
<i class="bi bi-pencil" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public bool ShowSearchBox { get; set; } = true;
|
||||
[Parameter] public string SearchTerm { get; set; } = "";
|
||||
[Parameter] public EventCallback<string> SearchTermChanged { get; set; }
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public int PendingChangesCount { get; set; }
|
||||
[Parameter] public EventCallback OnExpandAll { get; set; }
|
||||
[Parameter] public EventCallback OnCollapseAll { get; set; }
|
||||
[Parameter] public EventCallback OnEnterEditMode { get; set; }
|
||||
[Parameter] public EventCallback OnExitEditModeWithConfirm { get; set; }
|
||||
[Parameter] public EventCallback OnOpenSaveModal { get; set; }
|
||||
|
||||
private async Task OnSearchInput(ChangeEventArgs e)
|
||||
{
|
||||
await SearchTermChanged.InvokeAsync(e.Value?.ToString() ?? "");
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Base for effective-config custom value displays. Provides shared parameters,
|
||||
/// <see cref="NotifyValueChanged"/>, and <see cref="GetEffectiveDisplayValue"/> so pending changes are shown before save.
|
||||
/// </summary>
|
||||
public abstract class EffectiveConfigValueDisplayBase : ComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
public bool IsActiveEditor { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<object?> ValueChanged { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public IEffectiveConfigEditSession? Session { get; set; }
|
||||
|
||||
protected Task NotifyValueChanged(object? value) => ValueChanged.InvokeAsync(value);
|
||||
|
||||
/// <summary>Returns the value to display: pending change NewValue if this field has one, else descriptor value (not yet written to disk).</summary>
|
||||
protected object? GetEffectiveDisplayValue()
|
||||
{
|
||||
var pending = Session?.PendingChanges.FirstOrDefault(c =>
|
||||
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||
return pending != null ? pending.NewValue : Descriptor.GetValue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<ConfigOptionHelpModal HelpKey="@HelpKey" AnchorId="@AnchorId" Visible="@Visible" Title="@Title"
|
||||
OnClose="RequestClose" OnMouseEnterModal="CancelCloseTimer" OnMouseLeaveModal="ScheduleClose" />
|
||||
|
||||
@code {
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
[Parameter] public string? HelpKey { get; set; }
|
||||
[Parameter] public string? AnchorId { get; set; }
|
||||
[Parameter] public string Title { get; set; } = "Option help";
|
||||
[Parameter] public EventCallback OnCloseRequested { get; set; }
|
||||
|
||||
private CancellationTokenSource? _closeCts;
|
||||
|
||||
private void CancelCloseTimer()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = null;
|
||||
}
|
||||
|
||||
private void ScheduleClose()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = new CancellationTokenSource();
|
||||
var token = _closeCts.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await Task.Delay(300, token); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
_closeCts = null;
|
||||
await OnCloseRequested.InvokeAsync();
|
||||
});
|
||||
}, token);
|
||||
}
|
||||
|
||||
private async Task RequestClose()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
await OnCloseRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Called by the section when the label loses mouse (so we schedule close; if user moves to modal, OnMouseEnterModal cancels).</summary>
|
||||
public void ScheduleCloseFromLabelLeave()
|
||||
{
|
||||
ScheduleClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<span id="@AnchorId" class="@CssClass" title="@Title" @onclick="OnClick" @onmouseleave="OnMouseLeave">
|
||||
@ChildContent
|
||||
</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string AnchorId { get; set; } = "";
|
||||
[Parameter] public string? HelpKey { get; set; }
|
||||
[Parameter] public string OptionLabel { get; set; } = "";
|
||||
[Parameter] public string? CssClass { get; set; }
|
||||
[Parameter] public string? Title { get; set; }
|
||||
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
private Task OnClick()
|
||||
{
|
||||
if (Ui == null || string.IsNullOrEmpty(HelpKey)) return Task.CompletedTask;
|
||||
return Ui.RequestOptionHelpAsync(new ConfigOptionHelpRequestEventArgs
|
||||
{
|
||||
HelpKey = HelpKey,
|
||||
OptionLabel = OptionLabel,
|
||||
AnchorId = AnchorId
|
||||
});
|
||||
}
|
||||
|
||||
private Task OnMouseLeave()
|
||||
{
|
||||
if (Ui == null) return Task.CompletedTask;
|
||||
return Ui.RequestOptionHelpAsync(new ConfigOptionHelpRequestEventArgs
|
||||
{
|
||||
AnchorId = AnchorId,
|
||||
IsLabelMouseLeave = true
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
@if (Source?.IsReadOnly == true)
|
||||
{
|
||||
@if (IsInteractive && Ui != null)
|
||||
{
|
||||
<button type="button" id="@AnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Source?.GetReadOnlyTooltip())"
|
||||
@onclick="OnClick" @onmouseleave="OnMouseLeave">
|
||||
readonly
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary ec-badge-inline ms-1" title="@(Source?.GetReadOnlyTooltip())">readonly</span>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string AnchorId { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public object? Value { get; set; }
|
||||
/// <summary>When true, render as button and raise click/mouseleave via context.</summary>
|
||||
[Parameter] public bool IsInteractive { get; set; }
|
||||
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
|
||||
|
||||
private Task OnClick()
|
||||
{
|
||||
if (Ui == null || Source == null) return Task.CompletedTask;
|
||||
return Ui.RequestReadonlyPopoverAsync(new ReadonlyBadgeClickedEventArgs(AnchorId, Source, OptionName, Value));
|
||||
}
|
||||
|
||||
private Task OnMouseLeave()
|
||||
{
|
||||
if (Ui == null) return Task.CompletedTask;
|
||||
return Ui.ScheduleReadonlyPopoverCloseAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
@if (Source is { IsReadOnly: true })
|
||||
{
|
||||
@@ -9,17 +8,15 @@
|
||||
<span class="d-block">@ReadonlyConfigCommandGenerator.GetEditFileHint(Source)</span>
|
||||
@if (!string.IsNullOrEmpty(_removeCommand))
|
||||
{
|
||||
<div class="d-flex align-items-center gap-1 mt-1">
|
||||
<code class="flex-grow-1 small text-break">@_removeCommand</code>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyRemoveCommand" title="Copy command">Copy</button>
|
||||
<div class="mt-1">
|
||||
<CopyableCodeLine Text="@_removeCommand" CssClass="small" CodeCssClass="small text-break" CopyTitle="Copy command" />
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(_overrideLine))
|
||||
{
|
||||
<span class="d-block mt-1">@ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)</span>
|
||||
<div class="d-flex align-items-center gap-1 mt-1">
|
||||
<code class="flex-grow-1 small">@_overrideLine</code>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyOverrideLine" title="Copy line">Copy</button>
|
||||
<div class="mt-1">
|
||||
<CopyableCodeLine Text="@_overrideLine" CodeCssClass="small" CopyTitle="Copy line" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -47,30 +44,4 @@
|
||||
_overrideLine = "";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopyRemoveCommand()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_removeCommand)) return;
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("copyToClipboard", _removeCommand);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Copy not supported or failed
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopyOverrideLine()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_overrideLine)) return;
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("copyToClipboard", _overrideLine);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Copy not supported or failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,18 +19,14 @@
|
||||
<p class="text-muted mb-2">@ReadonlyConfigCommandGenerator.GetEditFileHint(Source)</p>
|
||||
@if (!string.IsNullOrEmpty(_removeCommand))
|
||||
{
|
||||
<div class="d-flex align-items-center gap-1 mb-2">
|
||||
<code class="flex-grow-1 text-break" style="font-size: 0.8rem;">@_removeCommand</code>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyRemoveCommand" title="Copy command">Copy</button>
|
||||
<div class="mb-2">
|
||||
<CopyableCodeLine Text="@_removeCommand" CodeCssClass="small" CopyTitle="Copy command" />
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(_overrideLine))
|
||||
{
|
||||
<p class="text-muted mb-1">@ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)</p>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<code class="flex-grow-1" style="font-size: 0.8rem;">@_overrideLine</code>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyOverrideLine" title="Copy line">Copy</button>
|
||||
</div>
|
||||
<CopyableCodeLine Text="@_overrideLine" CodeCssClass="small" CopyTitle="Copy line" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,6 +44,9 @@
|
||||
[Parameter] public EventCallback OnMouseEnterPopover { get; set; }
|
||||
[Parameter] public EventCallback OnMouseLeavePopover { get; set; }
|
||||
|
||||
private const string HiddenPositionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;";
|
||||
private const string VisiblePositionStyleFormat = "position: fixed; top: {0}px; left: {1}px; visibility: visible;";
|
||||
|
||||
private string _positionStyle = "";
|
||||
private readonly string _popoverId = "ec-readonly-popover-" + Guid.NewGuid().ToString("N")[..8];
|
||||
private DotNetObjectReference<ReadonlyEditPopover>? _dotNetRef;
|
||||
@@ -71,7 +70,7 @@
|
||||
_overrideLine = "";
|
||||
}
|
||||
if (Visible && string.IsNullOrEmpty(_positionStyle))
|
||||
_positionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;";
|
||||
_positionStyle = HiddenPositionStyle;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
@@ -143,11 +142,11 @@
|
||||
var gap = 4;
|
||||
var topPx = r.Bottom + gap;
|
||||
var leftPx = r.Left;
|
||||
_positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;";
|
||||
_positionStyle = string.Format(VisiblePositionStyleFormat, topPx, leftPx);
|
||||
}
|
||||
else
|
||||
{
|
||||
_positionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;";
|
||||
_positionStyle = HiddenPositionStyle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,18 +162,6 @@
|
||||
await OnMouseLeavePopover.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task CopyRemoveCommand()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_removeCommand)) return;
|
||||
try { await JSRuntime.InvokeVoidAsync("copyToClipboard", _removeCommand); } catch { }
|
||||
}
|
||||
|
||||
private async Task CopyOverrideLine()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_overrideLine)) return;
|
||||
try { await JSRuntime.InvokeVoidAsync("copyToClipboard", _overrideLine); } catch { }
|
||||
}
|
||||
|
||||
private sealed record BoundingRect(
|
||||
[property: JsonPropertyName("top")] double Top,
|
||||
[property: JsonPropertyName("left")] double Left,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<ReadonlyEditPopover Visible="@Visible" AnchorId="@AnchorId" Source="@Source" OptionName="@OptionName" Value="@Value" ManagedFilePath="@ManagedFilePath"
|
||||
OnClose="RequestClose" OnMouseEnterPopover="CancelCloseTimer" OnMouseLeavePopover="ScheduleClose" />
|
||||
|
||||
@code {
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
[Parameter] public string? AnchorId { get; set; }
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public object? Value { get; set; }
|
||||
[Parameter] public string? ManagedFilePath { get; set; }
|
||||
[Parameter] public EventCallback OnCloseRequested { get; set; }
|
||||
|
||||
private CancellationTokenSource? _closeCts;
|
||||
|
||||
private void CancelCloseTimer()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = null;
|
||||
}
|
||||
|
||||
private void ScheduleClose()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = new CancellationTokenSource();
|
||||
var token = _closeCts.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await Task.Delay(300, token); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
_closeCts = null;
|
||||
await OnCloseRequested.InvokeAsync();
|
||||
});
|
||||
}, token);
|
||||
}
|
||||
|
||||
private async Task RequestClose()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
await OnCloseRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when mouse leaves readonly badge; close unless pointer enters popover quickly.
|
||||
/// </summary>
|
||||
public void ScheduleCloseFromBadgeLeave()
|
||||
{
|
||||
ScheduleClose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when readonly badge is interacted with to keep popover open.
|
||||
/// </summary>
|
||||
public void CancelCloseFromBadgeEnter()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Scoped orchestrator for effective-config edit lifecycle: edit mode, active field, pending changes, and apply.
|
||||
/// Components read state from here and call methods; the owning section re-renders after state changes.
|
||||
/// </summary>
|
||||
public interface IEffectiveConfigEditSession : IApplicationScopedService
|
||||
{
|
||||
bool IsEditMode { get; }
|
||||
string? ActiveFieldKey { get; }
|
||||
IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; }
|
||||
|
||||
void EnterEditMode();
|
||||
void ExitEditModeDiscard();
|
||||
void ActivateField(string fieldKey);
|
||||
void DeactivateField();
|
||||
|
||||
void TrackCommit(EffectiveConfigEditCommittedArgs args);
|
||||
void RevertChange(string sectionId, string optionName);
|
||||
|
||||
Task ApplyAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
||||
{
|
||||
private readonly IDnsmasqConfigService _configService;
|
||||
private readonly List<PendingEffectiveConfigChange> _pending = new();
|
||||
|
||||
public EffectiveConfigEditSession(IDnsmasqConfigService configService)
|
||||
{
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public bool IsEditMode { get; private set; }
|
||||
public string? ActiveFieldKey { get; private set; }
|
||||
public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges => _pending;
|
||||
|
||||
public void EnterEditMode()
|
||||
{
|
||||
IsEditMode = true;
|
||||
ActiveFieldKey = null;
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
public void ExitEditModeDiscard()
|
||||
{
|
||||
_pending.Clear();
|
||||
ActiveFieldKey = null;
|
||||
IsEditMode = false;
|
||||
}
|
||||
|
||||
public void ActivateField(string fieldKey)
|
||||
{
|
||||
IsEditMode = true;
|
||||
ActiveFieldKey = fieldKey;
|
||||
}
|
||||
|
||||
public void DeactivateField()
|
||||
{
|
||||
ActiveFieldKey = null;
|
||||
}
|
||||
|
||||
public void TrackCommit(EffectiveConfigEditCommittedArgs args)
|
||||
{
|
||||
var existing = _pending.FirstOrDefault(c =>
|
||||
c.SectionId == args.SectionId && c.OptionName == args.OptionName);
|
||||
if (existing != null)
|
||||
_pending.Remove(existing);
|
||||
if (!Equals(args.OldValue, args.NewValue))
|
||||
_pending.Add(new PendingEffectiveConfigChange(
|
||||
args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath));
|
||||
ActiveFieldKey = null;
|
||||
}
|
||||
|
||||
public void RevertChange(string sectionId, string optionName)
|
||||
{
|
||||
var existing = _pending.FirstOrDefault(c =>
|
||||
c.SectionId == sectionId && c.OptionName == optionName);
|
||||
if (existing != null)
|
||||
_pending.Remove(existing);
|
||||
}
|
||||
|
||||
public async Task ApplyAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_pending.Count == 0) return;
|
||||
var changes = _pending.ToList();
|
||||
await _configService.ApplyEffectiveConfigChangesAsync(changes, ct);
|
||||
ExitEditModeDiscard();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Cascaded context for effective-config UI intents. Children call these methods instead of
|
||||
/// receiving 6–8 event callbacks; the section (or shell) provides the implementation.
|
||||
/// </summary>
|
||||
public sealed class EffectiveConfigUiContext
|
||||
{
|
||||
public required Func<ConfigOptionHelpRequestEventArgs, Task> RequestOptionHelpAsync { get; init; }
|
||||
public required Func<ReadonlyBadgeClickedEventArgs, Task> RequestReadonlyPopoverAsync { get; init; }
|
||||
public required Func<Task> ScheduleReadonlyPopoverCloseAsync { get; init; }
|
||||
public required Func<string, Task> ActivateFieldAsync { get; init; }
|
||||
public required Func<EffectiveConfigEditCommittedArgs, Task> CommitFieldAsync { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user