mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-22 13:57:50 +10:00
Hosts UX, effective names, JS interop, domain UI
- Hosts: delete confirmation modal, shift range select, keyboard shortcuts, effective-name suffix tooltips, conditional modern header actions - Align EffectiveNames on rows with scoped domain= + pending preview; unified hosts API uses full DomainValues from config snapshot - Harden Blazor JS interop (safe helpers, IAsyncDisposable, disposal order) - Effective config: domain multi-value display/editor, parsers, tests - Misc: docs/testdata/docker script updates
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Extensions.Interop
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<div class="d-flex align-items-center gap-1 @CssClass">
|
||||
@@ -15,13 +16,7 @@
|
||||
private async Task CopyAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Text)) return;
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("copyToClipboard", Text);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Clipboard not available or failed
|
||||
}
|
||||
// window.copyToClipboard from wwwroot/js/copy-to-clipboard.js; swallow disconnect / clipboard failures.
|
||||
await JSRuntime.InvokeVoidAsyncSafe("copyToClipboard", args: new object?[] { Text });
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Components.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Base for custom effective-config multi-value displays; mirrors <see cref="EffectiveConfigValueDisplayBase"/> for singles.
|
||||
/// </summary>
|
||||
public abstract class EffectiveConfigMultiValueDisplayBase : ComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public EffectiveConfigMultiDisplayContext Context { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
|
||||
|
||||
protected IReadOnlyList<ValueWithSource> EffectiveItems => Context.EffectiveItems;
|
||||
|
||||
protected IReadOnlyList<string> EffectiveValues => Context.EffectiveValues;
|
||||
|
||||
protected Task NotifyValuesChanged(IReadOnlyList<string> values) => OnValuesChanged.InvokeAsync(values);
|
||||
}
|
||||
+8
-4
@@ -163,15 +163,19 @@
|
||||
{
|
||||
if (Session.Draft.PendingChanges.Count > 0)
|
||||
{
|
||||
var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
|
||||
var module = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/dialog.js");
|
||||
if (module == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var confirmed = await module.InvokeAsync<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
|
||||
if (!confirmed) return;
|
||||
var confirmed = await module.InvokeAsyncSafe<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
|
||||
if (confirmed != true)
|
||||
return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await module.DisposeAsync();
|
||||
await module.DisposeAsyncSafe();
|
||||
}
|
||||
}
|
||||
Session.Draft.DiscardAllDraft();
|
||||
|
||||
+493
@@ -0,0 +1,493 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
<div class="ec-field-value ec-field-value-multi ec-domain-rule-editor" @onclick:stopPropagation="true">
|
||||
@* ul: avoid <ol> markers (scoped CSS does not inherit .ec-multi-list-add-item list-style rules from the generic multi editor). *@
|
||||
<ul class="ec-field-value-list ec-domain-rule-list">
|
||||
@if (_displayItems.Count > 0)
|
||||
{
|
||||
@for (var i = 0; i < _displayItems.Count; i++)
|
||||
{
|
||||
var item = _displayItems[i];
|
||||
var anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}";
|
||||
var itemReadonly = item.Source?.IsReadOnly == true;
|
||||
var rowIndex = i;
|
||||
var rowValue = item.Value;
|
||||
|
||||
<li class="ec-domain-rule-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-domain-rule-row-editing" : "")" @key="anchorId">
|
||||
@if (_editingIndex == rowIndex && !itemReadonly)
|
||||
{
|
||||
<div class="ec-domain-rule-form">
|
||||
<p class="ec-domain-rule-intro">Sets the domain suffix dnsmasq uses: DHCP domain option, which FQDNs hosts may claim, and (with <code>expand-hosts</code>) how simple names are qualified in DNS.</p>
|
||||
<label class="form-label">Edit as raw line</label>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-raw")"
|
||||
checked="@_draftUseRaw"
|
||||
@onchange="@(e => OnDraftUseRawChanged(e.Value is true))" />
|
||||
<label class="form-check-label" for="@($"{anchorId}-raw")">Edit as single line (advanced)</label>
|
||||
</div>
|
||||
@if (_draftUseRaw)
|
||||
{
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="2"
|
||||
@bind="_draftRawLine" @bind:event="oninput"
|
||||
placeholder="domain[,subnet|IP|interface[,local]]"></textarea>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div>
|
||||
<label class="form-label" for="@($"{anchorId}-dom")">Domain</label>
|
||||
<input id="@($"{anchorId}-dom")" class="form-control form-control-sm"
|
||||
placeholder="e.g. home.arpa or #"
|
||||
@bind="_draftDomain" @bind:event="oninput" />
|
||||
<div class="ec-domain-rule-hint">Use <code>#</code> to take the search domain from resolv.conf.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="@($"{anchorId}-scope")">Applies to</label>
|
||||
<select id="@($"{anchorId}-scope")" class="form-select form-select-sm"
|
||||
@bind="_draftScopeSelect" @bind:after="OnDraftScopeAfterChange">
|
||||
<option value="unconditional">Any client address (default)</option>
|
||||
<option value="range">Limit to this subnet or IP</option>
|
||||
<option value="interface">Limit to subnets on this interface</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (_draftScopeSelect == "range")
|
||||
{
|
||||
<div>
|
||||
<label class="form-label" for="@($"{anchorId}-mid")">Subnet or IP</label>
|
||||
<input id="@($"{anchorId}-mid")" class="form-control form-control-sm"
|
||||
placeholder="e.g. 192.168.0.0/24 or 192.168.0.1"
|
||||
@bind="_draftMiddle" @bind:event="oninput" />
|
||||
<div class="ec-domain-rule-hint">Single IP, CIDR, or start,end pair per dnsmasq docs.</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc")"
|
||||
@bind="_draftAddLocal" />
|
||||
<label class="form-check-label" for="@($"{anchorId}-loc")">
|
||||
Also add matching <code>--local</code> forward and reverse DNS
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
else if (_draftScopeSelect == "interface")
|
||||
{
|
||||
<div>
|
||||
<label class="form-label" for="@($"{anchorId}-if")">Interface</label>
|
||||
<input id="@($"{anchorId}-if")" class="form-control form-control-sm"
|
||||
placeholder="e.g. eth0 or br0"
|
||||
@bind="_draftMiddle" @bind:event="oninput" />
|
||||
</div>
|
||||
}
|
||||
else if (_draftScopeSelect == "unconditional")
|
||||
{
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc2")"
|
||||
@bind="_draftAddLocal" />
|
||||
<label class="form-check-label" for="@($"{anchorId}-loc2")">
|
||||
Append <code>,local</code> (same as a lone <code>--local</code> for this name; uncommon)
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="ec-domain-rule-actions">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
|
||||
Title="Apply change" AriaLabel="Apply change"
|
||||
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard edit" AriaLabel="Cancel edit"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="ec-domain-rule-summary">
|
||||
<span class="ec-field-value-item">@DisplayLine(rowValue)</span>
|
||||
@if (!string.Equals(DomainRuleFields.FormatSummary(rowValue), rowValue, StringComparison.Ordinal))
|
||||
{
|
||||
<code class="text-muted small">@rowValue</code>
|
||||
}
|
||||
<span class="status-badge-inline">
|
||||
<OptionStateBadge
|
||||
AnchorId="@anchorId"
|
||||
Source="@item.Source"
|
||||
OptionName="@OptionName"
|
||||
Value="@rowValue"
|
||||
IsInteractive="@IsEditMode"
|
||||
ShowEditableBadge="@(!itemReadonly)"
|
||||
EditableAsButton="@(!itemReadonly)"
|
||||
OnEditableClickRequested="@(!itemReadonly ? EventCallback.Factory.Create(this, () => { BeginEdit(rowIndex, rowValue); return Task.CompletedTask; }) : default)" />
|
||||
@if (!itemReadonly)
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
|
||||
Title="Remove this value" AriaLabel="Remove"
|
||||
PreventBlurOnMouseDown="true"
|
||||
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
else if (!AllowAddManagedValue)
|
||||
{
|
||||
<li class="ec-multi-list-row ec-multi-list-empty-hint">
|
||||
<span class="text-muted">(none)</span>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (AllowAddManagedValue)
|
||||
{
|
||||
<li class="ec-multi-list-row ec-multi-list-add-item @(_addingNew ? "ec-multi-list-row-with-controls" : "")" @key="AddItemKey">
|
||||
@if (_addingNew)
|
||||
{
|
||||
<div class="ec-domain-rule-form">
|
||||
<p class="ec-domain-rule-intro">Sets the domain suffix dnsmasq uses: DHCP domain option, which FQDNs hosts may claim, and (with <code>expand-hosts</code>) how simple names are qualified in DNS.</p>
|
||||
<label class="form-label">Edit as raw line</label>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="domain-add-raw"
|
||||
checked="@_draftUseRaw"
|
||||
@onchange="@(e => OnDraftUseRawChanged(e.Value is true))" />
|
||||
<label class="form-check-label" for="domain-add-raw">Edit as single line (advanced)</label>
|
||||
</div>
|
||||
@if (_draftUseRaw)
|
||||
{
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="2"
|
||||
@bind="_draftRawLine" @bind:event="oninput"
|
||||
placeholder="domain[,subnet|IP|interface[,local]]"></textarea>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div>
|
||||
<label class="form-label" for="domain-add-dom">Domain</label>
|
||||
<input id="domain-add-dom" class="form-control form-control-sm"
|
||||
placeholder="e.g. home.arpa or #"
|
||||
@bind="_draftDomain" @bind:event="oninput" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="domain-add-scope">Applies to</label>
|
||||
<select id="domain-add-scope" class="form-select form-select-sm"
|
||||
@bind="_draftScopeSelect" @bind:after="OnDraftScopeAfterChange">
|
||||
<option value="unconditional">Any client address (default)</option>
|
||||
<option value="range">Limit to this subnet or IP</option>
|
||||
<option value="interface">Limit to subnets on this interface</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (_draftScopeSelect == "range")
|
||||
{
|
||||
<div>
|
||||
<label class="form-label" for="domain-add-mid">Subnet or IP</label>
|
||||
<input id="domain-add-mid" class="form-control form-control-sm"
|
||||
placeholder="e.g. 192.168.0.0/24"
|
||||
@bind="_draftMiddle" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="domain-add-loc"
|
||||
@bind="_draftAddLocal" />
|
||||
<label class="form-check-label" for="domain-add-loc">
|
||||
Also add matching <code>--local</code> forward and reverse DNS
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
else if (_draftScopeSelect == "interface")
|
||||
{
|
||||
<div>
|
||||
<label class="form-label" for="domain-add-if">Interface</label>
|
||||
<input id="domain-add-if" class="form-control form-control-sm"
|
||||
placeholder="e.g. eth0"
|
||||
@bind="_draftMiddle" @bind:event="oninput" />
|
||||
</div>
|
||||
}
|
||||
else if (_draftScopeSelect == "unconditional")
|
||||
{
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="domain-add-loc2"
|
||||
@bind="_draftAddLocal" />
|
||||
<label class="form-check-label" for="domain-add-loc2">
|
||||
Append <code>,local</code> (uncommon)
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="ec-domain-rule-actions">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
|
||||
Title="Add this value" AriaLabel="Add"
|
||||
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard" AriaLabel="Cancel"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.Editable" Text="add item" Clickable="true"
|
||||
Title="Add a new value" AriaLabel="Add item"
|
||||
OnClick="BeginAdd" Class="status-badge-inline ec-multi-list-add-btn" />
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "domain";
|
||||
[Parameter] public string ReadonlyBadgeAnchorIdBase { get; set; } = "";
|
||||
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public bool AllowAddManagedValue { get; set; } = true;
|
||||
|
||||
[Parameter] public IMultiValueEditBehavior? Behavior { get; set; }
|
||||
[Parameter] public IMultiValueOptionValidator? Validator { get; set; }
|
||||
[Parameter] public string? FieldKey { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
|
||||
|
||||
private const string AddItemKey = "domain-add-item";
|
||||
private static readonly TimeSpan ValidationToastDedupeWindow = TimeSpan.FromSeconds(2);
|
||||
|
||||
private int? _editingIndex;
|
||||
private bool _addingNew;
|
||||
private bool _didAutoOpenAdd;
|
||||
private string? _lastValidationErrorMessage;
|
||||
private DateTime _lastValidationErrorUtc;
|
||||
|
||||
private List<ValueWithSource> _displayItems = new();
|
||||
|
||||
private bool _draftUseRaw;
|
||||
private string _draftRawLine = "";
|
||||
private string _draftDomain = "";
|
||||
private string _draftScopeSelect = "unconditional";
|
||||
private string _draftMiddle = "";
|
||||
private bool _draftAddLocal;
|
||||
|
||||
private IReadOnlyList<string> _currentValues => _displayItems.Select(x => x.Value).ToList();
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_displayItems = (SourceItems ?? Array.Empty<ValueWithSource>()).ToList();
|
||||
|
||||
if (!_didAutoOpenAdd && (SourceItems?.Count ?? 0) == 0 && AllowAddManagedValue)
|
||||
{
|
||||
_didAutoOpenAdd = true;
|
||||
_addingNew = true;
|
||||
ResetDraftForNew();
|
||||
}
|
||||
}
|
||||
|
||||
private static string DisplayLine(string rowValue) =>
|
||||
DomainRuleFields.FormatSummary(rowValue);
|
||||
|
||||
private void ResetDraftForNew()
|
||||
{
|
||||
_draftUseRaw = false;
|
||||
_draftRawLine = "";
|
||||
_draftDomain = "";
|
||||
_draftScopeSelect = "unconditional";
|
||||
_draftMiddle = "";
|
||||
_draftAddLocal = false;
|
||||
}
|
||||
|
||||
private void BeginEdit(int index, string current)
|
||||
{
|
||||
_addingNew = false;
|
||||
_editingIndex = index;
|
||||
LoadDraftFromLine(current);
|
||||
}
|
||||
|
||||
private void BeginAdd()
|
||||
{
|
||||
_editingIndex = null;
|
||||
_addingNew = true;
|
||||
ResetDraftForNew();
|
||||
}
|
||||
|
||||
private void CancelInline()
|
||||
{
|
||||
_editingIndex = null;
|
||||
_addingNew = false;
|
||||
_lastValidationErrorMessage = null;
|
||||
ResetDraftForNew();
|
||||
}
|
||||
|
||||
private void LoadDraftFromLine(string line)
|
||||
{
|
||||
var parsed = DomainRuleFields.Parse(line);
|
||||
if (parsed.IsRaw)
|
||||
{
|
||||
_draftUseRaw = true;
|
||||
_draftRawLine = parsed.RawLineFallback ?? line;
|
||||
_draftDomain = "";
|
||||
_draftScopeSelect = "unconditional";
|
||||
_draftMiddle = "";
|
||||
_draftAddLocal = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_draftUseRaw = false;
|
||||
_draftRawLine = "";
|
||||
_draftDomain = parsed.DomainPart;
|
||||
_draftScopeSelect = parsed.Scope switch
|
||||
{
|
||||
DomainRuleScope.AddressRangeOrIp => "range",
|
||||
DomainRuleScope.InterfaceName => "interface",
|
||||
_ => "unconditional",
|
||||
};
|
||||
_draftMiddle = parsed.MiddlePart;
|
||||
_draftAddLocal = parsed.AddLocalForRange;
|
||||
}
|
||||
|
||||
private void OnDraftUseRawChanged(bool useRaw)
|
||||
{
|
||||
if (useRaw && !_draftUseRaw)
|
||||
{
|
||||
var fields = BuildFieldsFromDraftStructured();
|
||||
_draftRawLine = fields.ToConfigLine();
|
||||
}
|
||||
else if (!useRaw && _draftUseRaw)
|
||||
LoadDraftFromLine(_draftRawLine.Trim());
|
||||
|
||||
_draftUseRaw = useRaw;
|
||||
}
|
||||
|
||||
private void OnDraftScopeAfterChange()
|
||||
{
|
||||
if (_draftScopeSelect == "unconditional")
|
||||
_draftMiddle = "";
|
||||
if (_draftScopeSelect == "interface")
|
||||
_draftAddLocal = false;
|
||||
}
|
||||
|
||||
private DomainRuleFields BuildFieldsFromDraftStructured()
|
||||
{
|
||||
var dom = (_draftDomain ?? "").Trim();
|
||||
var mid = (_draftMiddle ?? "").Trim();
|
||||
var scope = _draftScopeSelect switch
|
||||
{
|
||||
"range" => DomainRuleScope.AddressRangeOrIp,
|
||||
"interface" => DomainRuleScope.InterfaceName,
|
||||
_ => DomainRuleScope.Unconditional,
|
||||
};
|
||||
|
||||
if (scope == DomainRuleScope.Unconditional)
|
||||
return new DomainRuleFields(dom, DomainRuleScope.Unconditional, "", _draftAddLocal, null);
|
||||
|
||||
if (scope == DomainRuleScope.InterfaceName)
|
||||
return new DomainRuleFields(dom, DomainRuleScope.InterfaceName, mid, false, null);
|
||||
|
||||
if (mid.Length == 0 && _draftAddLocal)
|
||||
return new DomainRuleFields(dom, DomainRuleScope.Unconditional, "", true, null);
|
||||
|
||||
return new DomainRuleFields(dom, DomainRuleScope.AddressRangeOrIp, mid, _draftAddLocal, null);
|
||||
}
|
||||
|
||||
private DomainRuleFields BuildFieldsFromDraft() =>
|
||||
_draftUseRaw ? DomainRuleFields.FromRawConfigLine(_draftRawLine) : BuildFieldsFromDraftStructured();
|
||||
|
||||
private void ShowValidationError(string message)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (string.Equals(_lastValidationErrorMessage, message, StringComparison.Ordinal) &&
|
||||
(now - _lastValidationErrorUtc) < ValidationToastDedupeWindow)
|
||||
return;
|
||||
_lastValidationErrorMessage = message;
|
||||
_lastValidationErrorUtc = now;
|
||||
NotificationService.ShowError(message);
|
||||
}
|
||||
|
||||
private async Task SaveEdit(int index)
|
||||
{
|
||||
var next = _currentValues.ToList();
|
||||
if (index < 0 || index >= next.Count) return;
|
||||
|
||||
var fields = BuildFieldsFromDraft();
|
||||
var normalized = fields.ToConfigLine();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
ShowValidationError("Domain name (or #) is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_draftUseRaw && _draftScopeSelect == "interface" && string.IsNullOrWhiteSpace(_draftMiddle))
|
||||
{
|
||||
ShowValidationError("Interface name is required for this scope.");
|
||||
return;
|
||||
}
|
||||
|
||||
normalized = Behavior?.Normalize(normalized) ?? normalized.Trim();
|
||||
|
||||
var validationError = Validator?.ValidateItem(normalized, _currentValues, index);
|
||||
if (validationError != null)
|
||||
{
|
||||
ShowValidationError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Behavior?.AllowDuplicates == false && next.Where((v, i) => i != index).Any(v => string.Equals(v, normalized, StringComparison.Ordinal)))
|
||||
{
|
||||
ShowValidationError("Duplicate values are not allowed.");
|
||||
return;
|
||||
}
|
||||
|
||||
next[index] = normalized;
|
||||
_displayItems[index] = _displayItems[index] with { Value = normalized };
|
||||
await OnValuesChanged.InvokeAsync(next);
|
||||
CancelInline();
|
||||
}
|
||||
|
||||
private async Task SaveNew()
|
||||
{
|
||||
var fields = BuildFieldsFromDraft();
|
||||
var normalized = fields.ToConfigLine();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
ShowValidationError("Domain name (or #) is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_draftUseRaw && _draftScopeSelect == "interface" && string.IsNullOrWhiteSpace(_draftMiddle))
|
||||
{
|
||||
ShowValidationError("Interface name is required for this scope.");
|
||||
return;
|
||||
}
|
||||
|
||||
normalized = Behavior?.Normalize(normalized) ?? normalized.Trim();
|
||||
|
||||
var current = _currentValues;
|
||||
var validationError = Validator?.ValidateItem(normalized, current, null);
|
||||
if (validationError != null)
|
||||
{
|
||||
ShowValidationError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Behavior?.AllowDuplicates == false && current.Contains(normalized, StringComparer.Ordinal))
|
||||
{
|
||||
ShowValidationError("Duplicate values are not allowed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var next = current.ToList();
|
||||
next.Add(normalized);
|
||||
_displayItems.Add(new ValueWithSource(normalized, null));
|
||||
await OnValuesChanged.InvokeAsync(next);
|
||||
CancelInline();
|
||||
}
|
||||
|
||||
private async Task RemoveAt(int index)
|
||||
{
|
||||
if (index < 0 || index >= _displayItems.Count) return;
|
||||
var item = _displayItems[index];
|
||||
if (item?.Source?.IsReadOnly == true) return;
|
||||
|
||||
var next = _displayItems.Select(x => x.Value).ToList();
|
||||
next.RemoveAt(index);
|
||||
_displayItems.RemoveAt(index);
|
||||
await OnValuesChanged.InvokeAsync(next);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
.ec-domain-rule-editor {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--bs-body-bg, #fff);
|
||||
}
|
||||
|
||||
.ec-domain-rule-editor .ec-field-value-list,
|
||||
.ec-domain-rule-list {
|
||||
list-style: none;
|
||||
margin-bottom: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.ec-domain-rule-list > li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ec-domain-rule-intro {
|
||||
font-size: 0.8rem;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
margin-bottom: 0.35rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ec-domain-rule-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.ec-domain-rule-row.ec-domain-rule-row-editing {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ec-domain-rule-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 36rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.ec-domain-rule-form .form-label {
|
||||
margin-bottom: 0.15rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
}
|
||||
|
||||
.ec-domain-rule-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.ec-domain-rule-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.ec-domain-rule-raw {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ec-domain-rule-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig
|
||||
@inherits EffectiveConfigMultiValueDisplayBase
|
||||
|
||||
@if (Context.IsActiveEditor)
|
||||
{
|
||||
<DomainEffectiveConfigListEditor
|
||||
SourceItems="EffectiveItems"
|
||||
OptionName="@Context.Descriptor.OptionName"
|
||||
ReadonlyBadgeAnchorIdBase="@_anchorBase"
|
||||
IsEditMode="@Context.IsEditMode"
|
||||
AllowAddManagedValue="@Context.IsEditMode"
|
||||
FieldKey="@Context.FieldKey"
|
||||
Behavior="@_multiDesc?.Behavior"
|
||||
Validator="@_multiDesc?.Validator"
|
||||
OnValuesChanged="@NotifyValuesChanged" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="ec-field-value ec-field-value-multi">
|
||||
<ol class="ec-field-value-list">
|
||||
@if (Context.EffectiveItems.Count > 0)
|
||||
{
|
||||
@foreach (var (item, index) in Context.EffectiveItems.Select((item, index) => (item, index)))
|
||||
{
|
||||
var anchorId = $"{_anchorBase}-{index}";
|
||||
<li>
|
||||
<EffectiveConfigMultiValueItem
|
||||
Value="@item.Value"
|
||||
DisplayValue="@DomainRuleFields.FormatSummary(item.Value)"
|
||||
Source="@item.Source"
|
||||
OptionName="@Context.Descriptor.OptionName"
|
||||
IsEditMode="@Context.IsEditMode"
|
||||
ReadonlyBadgeAnchorId="@anchorId"
|
||||
FieldKey="@Context.FieldKey" />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
@if (Context.ShowEditableBadge && Context.EffectiveItems.Count > 0)
|
||||
{
|
||||
<li class="ec-multi-list-entry-point">
|
||||
<OptionStateBadge
|
||||
OptionName="@Context.Descriptor.OptionName"
|
||||
ShowEditableBadge="true"
|
||||
EditableAsButton="true"
|
||||
FieldKey="@Context.FieldKey"
|
||||
IsActiveEditor="false"
|
||||
IsInteractive="true"
|
||||
HasPendingChange="@Context.HasPendingOrDraft"
|
||||
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
|
||||
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
|
||||
</li>
|
||||
}
|
||||
else if (Context.EffectiveItems.Count == 0)
|
||||
{
|
||||
<li class="ec-multi-list-empty-hint">
|
||||
@if (Context.ShowEditableBadge)
|
||||
{
|
||||
<span class="text-muted">(none)</span>
|
||||
<OptionStateBadge
|
||||
OptionName="@Context.Descriptor.OptionName"
|
||||
ShowEditableBadge="true"
|
||||
EditableAsButton="true"
|
||||
FieldKey="@Context.FieldKey"
|
||||
IsActiveEditor="false"
|
||||
IsInteractive="true"
|
||||
HasPendingChange="@Context.HasPendingOrDraft"
|
||||
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
|
||||
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">(none)</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private readonly string _anchorBase = "ec-domain-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc =>
|
||||
Context.Descriptor as EffectiveMultiValueConfigFieldDescriptor;
|
||||
}
|
||||
+40
-63
@@ -45,30 +45,32 @@
|
||||
else if (Descriptor.IsMultiValue)
|
||||
{
|
||||
var customMulti = RenderFragmentRegistry.BuildMultiFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<IReadOnlyList<string>>(this, HandleMultiValueChanged));
|
||||
if (customMulti is not null)
|
||||
if (customMulti is not null && _multiContext 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" : "")">
|
||||
@* Custom multi: row badge only while this field is active (ok/cancel/revert). Otherwise per-item / entry badges in the fragment suffice — avoids duplicating "readonly" when RowSource matches item sources. *@
|
||||
var customMultiActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
|
||||
<li class="ec-field @(customMultiActiveEditor ? "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="@customMultiActiveEditor">
|
||||
<CascadingValue Value="@IsEditMode">
|
||||
@customMulti(Descriptor)
|
||||
@customMulti(_multiContext)
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</span>
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiValueRowSource" OptionName="@Descriptor.OptionName" Value="@_multiValueItems"
|
||||
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(!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" />
|
||||
@if (customMultiActiveEditor)
|
||||
{
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiContext.RowSource" OptionName="@Descriptor.OptionName" Value="@_multiContext.EffectiveItems"
|
||||
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(!Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiContext.HasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="true"
|
||||
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
|
||||
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
}
|
||||
</li>
|
||||
}
|
||||
else
|
||||
else if (_multiContext is not null)
|
||||
{
|
||||
@* 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="@(!Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
|
||||
<EffectiveConfigMultiValueRow Context="@_multiContext" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
||||
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
}
|
||||
}
|
||||
@@ -122,14 +124,7 @@ else
|
||||
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;
|
||||
private EffectiveConfigMultiDisplayContext? _multiContext;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
@@ -146,7 +141,15 @@ else
|
||||
_hasPendingChange = pending != null;
|
||||
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
|
||||
if (Descriptor.IsMultiValue)
|
||||
ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
||||
_multiContext = EffectiveConfigMultiDisplayContext.From(
|
||||
Descriptor,
|
||||
Descriptor.GetItems(),
|
||||
pending?.NewValue as IReadOnlyList<string>,
|
||||
_hasPendingChange,
|
||||
_draftValue as IReadOnlyList<string>,
|
||||
IsEditMode,
|
||||
IsActive,
|
||||
FieldKey);
|
||||
if (!_wasActive && IsActive)
|
||||
{
|
||||
// Use pending.OldValue when present so "revert to original" clears the pending change (e.g. do-0x20-encode: Disabled → Enable → Disable).
|
||||
@@ -161,45 +164,6 @@ else
|
||||
_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;
|
||||
@@ -279,7 +243,20 @@ else
|
||||
_draftValue = values.ToList();
|
||||
_hasDraftValue = true;
|
||||
if (Descriptor.IsMultiValue)
|
||||
ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
||||
{
|
||||
var pending = Session?.Draft.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
|
||||
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||
_multiContext = EffectiveConfigMultiDisplayContext.From(
|
||||
Descriptor,
|
||||
Descriptor.GetItems(),
|
||||
pending?.NewValue as IReadOnlyList<string>,
|
||||
pending != null,
|
||||
_draftValue as IReadOnlyList<string>,
|
||||
IsEditMode,
|
||||
IsActive,
|
||||
FieldKey);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -3,7 +3,7 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<span class="ec-field-value-item" title="@Source?.GetReadOnlyTooltip()">
|
||||
@Value
|
||||
@(DisplayValue ?? Value)
|
||||
<OptionStateBadge
|
||||
AnchorId="@(ReadonlyBadgeAnchorId ?? "")"
|
||||
Source="@Source"
|
||||
@@ -16,7 +16,10 @@
|
||||
</span>
|
||||
|
||||
@code {
|
||||
/// <summary>Raw config line for badges / popovers.</summary>
|
||||
[Parameter] public string Value { get; set; } = "";
|
||||
/// <summary>When set, shown instead of <see cref="Value"/> in the value span (e.g. friendly summary).</summary>
|
||||
[Parameter] public string? DisplayValue { get; set; }
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
|
||||
-1
@@ -4,7 +4,6 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
<div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
|
||||
|
||||
+35
-42
@@ -3,32 +3,32 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<li class="ec-field">
|
||||
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@LabelTooltip"><strong>@Label:</strong></OptionHelpLabel>
|
||||
@if (IsActiveEditor)
|
||||
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Context.Descriptor.OptionName" Title="@LabelTooltip"><strong>@Context.Descriptor.OptionName:</strong></OptionHelpLabel>
|
||||
@if (Context.IsActiveEditor)
|
||||
{
|
||||
<EffectiveConfigMultiValueListEditor
|
||||
SourceItems="@(Items ?? Array.Empty<ValueWithSource>())"
|
||||
OptionName="@Label"
|
||||
SourceItems="@(Context.EffectiveItems)"
|
||||
OptionName="@Context.Descriptor.OptionName"
|
||||
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
|
||||
IsEditMode="@IsEditMode"
|
||||
AllowAddManagedValue="@IsEditMode"
|
||||
FieldKey="@FieldKey"
|
||||
Behavior="@(_multiDesc?.Behavior)"
|
||||
Validator="@(_multiDesc?.Validator)"
|
||||
IsEditMode="@Context.IsEditMode"
|
||||
AllowAddManagedValue="@Context.IsEditMode"
|
||||
FieldKey="@Context.FieldKey"
|
||||
Behavior="@(MultiDesc?.Behavior)"
|
||||
Validator="@(MultiDesc?.Validator)"
|
||||
OnValuesChanged="@ItemsChanged" />
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
||||
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
||||
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
|
||||
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason"
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Context.RowSource" OptionName="@Context.Descriptor.OptionName" Value="@Context.EffectiveItems"
|
||||
IsInteractive="@Context.IsEditMode" ShowEditableBadge="@Context.ShowEditableBadge" HasPendingChange="@Context.HasPendingOrDraft"
|
||||
EditableAsButton="true" FieldKey="@Context.FieldKey" IsActiveEditor="@Context.IsActiveEditor"
|
||||
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason"
|
||||
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="ec-field-value ec-field-value-multi">
|
||||
<ol class="ec-field-value-list">
|
||||
@if (Items?.Count > 0)
|
||||
@if (Context.EffectiveItems.Count > 0)
|
||||
{
|
||||
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
|
||||
@foreach (var (item, index) in Context.EffectiveItems.Select((item, index) => (item, index)))
|
||||
{
|
||||
var anchorId = ReadonlyBadgeAnchorIdBase != null
|
||||
? $"{ReadonlyBadgeAnchorIdBase}-{index}"
|
||||
@@ -37,44 +37,44 @@
|
||||
<EffectiveConfigMultiValueItem
|
||||
Value="@item.Value"
|
||||
Source="@item.Source"
|
||||
OptionName="@Label"
|
||||
IsEditMode="@IsEditMode"
|
||||
OptionName="@Context.Descriptor.OptionName"
|
||||
IsEditMode="@Context.IsEditMode"
|
||||
ReadonlyBadgeAnchorId="@anchorId"
|
||||
FieldKey="@FieldKey" />
|
||||
FieldKey="@Context.FieldKey" />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
@if (ShowEditableBadge && (Items?.Count ?? 0) > 0)
|
||||
@if (Context.ShowEditableBadge && Context.EffectiveItems.Count > 0)
|
||||
{
|
||||
<li class="ec-multi-list-entry-point">
|
||||
<OptionStateBadge
|
||||
OptionName="@Label"
|
||||
OptionName="@Context.Descriptor.OptionName"
|
||||
ShowEditableBadge="true"
|
||||
EditableAsButton="true"
|
||||
FieldKey="@FieldKey"
|
||||
FieldKey="@Context.FieldKey"
|
||||
IsActiveEditor="false"
|
||||
IsInteractive="true"
|
||||
HasPendingChange="@HasPendingChange"
|
||||
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)"
|
||||
CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
|
||||
HasPendingChange="@Context.HasPendingOrDraft"
|
||||
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
|
||||
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
|
||||
</li>
|
||||
}
|
||||
else if ((Items?.Count ?? 0) == 0)
|
||||
else if (Context.EffectiveItems.Count == 0)
|
||||
{
|
||||
<li class="ec-multi-list-empty-hint">
|
||||
@if (ShowEditableBadge)
|
||||
@if (Context.ShowEditableBadge)
|
||||
{
|
||||
<span class="text-muted">(none)</span>
|
||||
<OptionStateBadge
|
||||
OptionName="@Label"
|
||||
OptionName="@Context.Descriptor.OptionName"
|
||||
ShowEditableBadge="true"
|
||||
EditableAsButton="true"
|
||||
FieldKey="@FieldKey"
|
||||
FieldKey="@Context.FieldKey"
|
||||
IsActiveEditor="false"
|
||||
IsInteractive="true"
|
||||
HasPendingChange="@HasPendingChange"
|
||||
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)"
|
||||
CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
|
||||
HasPendingChange="@Context.HasPendingOrDraft"
|
||||
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
|
||||
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -88,23 +88,16 @@
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
[Parameter] public EffectiveConfigFieldDescriptor? Descriptor { get; set; }
|
||||
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
|
||||
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
|
||||
[Parameter] public EffectiveConfigMultiDisplayContext Context { get; set; } = null!;
|
||||
[Parameter] public string? LabelTooltip { get; set; }
|
||||
[Parameter] public string? OptionHelpKey { get; set; }
|
||||
[Parameter] public string? OptionHelpAnchorId { get; set; }
|
||||
[Parameter] public bool IsActiveEditor { get; set; }
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ReadonlyBadgeAnchorIdBase { get; set; }
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public bool ShowEditableBadge { get; set; }
|
||||
[Parameter] public bool HasPendingChange { get; set; }
|
||||
[Parameter] public string FieldKey { get; set; } = "";
|
||||
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
|
||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||
|
||||
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc => Descriptor as EffectiveMultiValueConfigFieldDescriptor;
|
||||
private EffectiveMultiValueConfigFieldDescriptor? MultiDesc =>
|
||||
Context.Descriptor as EffectiveMultiValueConfigFieldDescriptor;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Extensions.DependencyInjection
|
||||
@using DnsmasqWebUI.Extensions.Interop
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.JSInterop
|
||||
@using System.Text.Json.Serialization
|
||||
@@ -57,14 +58,8 @@
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
catch (InvalidOperationException) { /* prerender */ }
|
||||
catch (JSDisconnectedException) { /* circuit disconnected */ }
|
||||
catch (JSException) { /* import failed */ }
|
||||
_jsModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/config-option-help.js");
|
||||
_moduleLoaded = _jsModule != null;
|
||||
}
|
||||
|
||||
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
|
||||
@@ -93,15 +88,21 @@
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_scrollListenerAdded && _jsModule != null)
|
||||
try
|
||||
{
|
||||
if (_scrollListenerAdded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = false;
|
||||
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scrollListenerAdded = false;
|
||||
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
|
||||
_dotNetRef?.Dispose();
|
||||
_dotNetRef = null;
|
||||
await _jsModule.DisposeAsyncSafe();
|
||||
_jsModule = null;
|
||||
}
|
||||
await _jsModule.DisposeAsyncSafe();
|
||||
_jsModule = null;
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Extensions
|
||||
@using DnsmasqWebUI.Extensions.Interop
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.JSInterop
|
||||
@using System.Text.Json.Serialization
|
||||
@@ -76,14 +77,8 @@
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
catch (JSDisconnectedException) { }
|
||||
catch (JSException) { }
|
||||
_jsModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/config-option-help.js");
|
||||
_moduleLoaded = _jsModule != null;
|
||||
}
|
||||
|
||||
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
|
||||
@@ -121,15 +116,21 @@
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_scrollListenerAdded && _jsModule != null)
|
||||
try
|
||||
{
|
||||
if (_scrollListenerAdded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = false;
|
||||
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scrollListenerAdded = false;
|
||||
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
|
||||
_dotNetRef?.Dispose();
|
||||
_dotNetRef = null;
|
||||
await _jsModule.DisposeAsyncSafe();
|
||||
_jsModule = null;
|
||||
}
|
||||
await _jsModule.DisposeAsyncSafe();
|
||||
_jsModule = null;
|
||||
}
|
||||
|
||||
private async Task PositionUnderAnchorAsync()
|
||||
|
||||
Reference in New Issue
Block a user