mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
Polish multi-value editors and hosts inline editing.
Align the domain custom editor with the default multi-value layout, keep item numbering in edit mode, and restore unaffected single-value editor styling. Refine hosts inline editing with safer blur handling, explicit cancel actions, and better behavior when blank edits are abandoned.
This commit is contained in:
+29
-14
@@ -172,6 +172,16 @@
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
/* Option label + field-level badges (editable / ok / cancel) on one row */
|
||||
::deep .ec-field-label-row {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem 0.5rem;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
::deep .ec-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -206,7 +216,7 @@
|
||||
/* When a field is active: value + badges in one block so any click inside (checkbox, input, ok/cancel) doesn't deactivate */
|
||||
::deep .ec-field-value-when-editing {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
@@ -239,11 +249,6 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Row-level ok/cancel (and revert) for multi-value: right-align on the row below the list */
|
||||
::deep .ec-field .ec-field-value-multi + .status-badge-inline {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
::deep .ec-field-value-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
@@ -262,18 +267,24 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Multivalue display mode: editable entry row (no list number; align with list content) */
|
||||
::deep .ec-field-value-list li.ec-multi-list-entry-point {
|
||||
list-style: none;
|
||||
margin-left: -1.25rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
::deep .ec-field-value-list li.ec-multi-list-empty-hint {
|
||||
list-style: none;
|
||||
margin-left: -1.25rem;
|
||||
}
|
||||
|
||||
/* Shared with default and custom (e.g. domain) multi-value editors */
|
||||
::deep .ec-field-value-list li.ec-multi-list-add-item {
|
||||
list-style: none;
|
||||
margin-left: -1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px dashed var(--bs-border-color, #dee2e6);
|
||||
}
|
||||
|
||||
::deep .ec-field-value-list li.ec-multi-list-add-item > .ec-multi-list-row-controls > .status-badge-inline {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Long paths/URLs wrap; allow break anywhere so nothing is clipped */
|
||||
::deep .ec-field-value-item {
|
||||
display: inline;
|
||||
@@ -300,7 +311,11 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
@media (max-width: 575.98px) {
|
||||
::deep .ec-field-value-when-editing {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
::deep .ec-field-value-when-editing > .status-badge-inline {
|
||||
flex-basis: 100%;
|
||||
margin-left: 0;
|
||||
|
||||
+203
-117
@@ -6,8 +6,7 @@
|
||||
@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">
|
||||
<ol class="ec-field-value-list ec-domain-rule-list">
|
||||
@if (_displayItems.Count > 0)
|
||||
{
|
||||
@for (var i = 0; i < _displayItems.Count; i++)
|
||||
@@ -19,116 +18,152 @@
|
||||
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 class="ec-domain-rule-row-body">
|
||||
@if (_editingIndex == rowIndex && !itemReadonly)
|
||||
{
|
||||
<div class="ec-domain-rule-form">
|
||||
<div class="ec-domain-rule-toolbar">
|
||||
<div class="ec-domain-rule-toolbar__group">
|
||||
<span class="form-label mb-0">Editor mode</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
|
||||
title="@DomainRuleEditorHelpText"
|
||||
aria-label="@DomainRuleEditorHelpText"
|
||||
@onclick="ShowDomainRuleEditorHelp">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ec-domain-rule-mode-toggle" role="group" aria-label="Domain rule editor mode">
|
||||
<button type="button"
|
||||
class="btn btn-sm ec-domain-rule-mode-btn @(!_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
|
||||
aria-pressed="@(!_draftUseRaw ? "true" : "false")"
|
||||
@onclick="() => OnDraftUseRawChanged(false)">
|
||||
Guided
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm ec-domain-rule-mode-btn @(_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
|
||||
aria-pressed="@(_draftUseRaw ? "true" : "false")"
|
||||
@onclick="() => OnDraftUseRawChanged(true)">
|
||||
Raw line
|
||||
</button>
|
||||
</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")
|
||||
@if (_draftUseRaw)
|
||||
{
|
||||
<input class="form-control form-control-sm font-monospace"
|
||||
@bind="_draftRawLine" @bind:event="oninput"
|
||||
placeholder="domain[,subnet|IP|interface[,local]]" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<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>
|
||||
<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-row">
|
||||
<div class="ec-domain-rule-hint">Use <code>#</code> to take the search domain from resolv.conf.</div>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
|
||||
title="@HashDomainHelpText"
|
||||
aria-label="@HashDomainHelpText"
|
||||
@onclick="ShowHashDomainHelp">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</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" />
|
||||
<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 ec-domain-rule-local-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>
|
||||
</label>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
|
||||
title="@LocalSuffixHelpText"
|
||||
aria-label="@LocalSuffixHelpText"
|
||||
@onclick="ShowLocalSuffixHelp">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</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 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>
|
||||
</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)
|
||||
}
|
||||
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))
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
|
||||
Title="Remove this value" AriaLabel="Remove"
|
||||
PreventBlurOnMouseDown="true"
|
||||
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
|
||||
<code class="text-muted small">@rowValue</code>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
@@ -145,19 +180,37 @@
|
||||
@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 class="ec-domain-rule-toolbar">
|
||||
<div class="ec-domain-rule-toolbar__group">
|
||||
<span class="form-label mb-0">Editor mode</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
|
||||
title="@DomainRuleEditorHelpText"
|
||||
aria-label="@DomainRuleEditorHelpText"
|
||||
@onclick="ShowDomainRuleEditorHelp">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ec-domain-rule-mode-toggle" role="group" aria-label="Domain rule editor mode">
|
||||
<button type="button"
|
||||
class="btn btn-sm ec-domain-rule-mode-btn @(!_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
|
||||
aria-pressed="@(!_draftUseRaw ? "true" : "false")"
|
||||
@onclick="() => OnDraftUseRawChanged(false)">
|
||||
Guided
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm ec-domain-rule-mode-btn @(_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
|
||||
aria-pressed="@(_draftUseRaw ? "true" : "false")"
|
||||
@onclick="() => OnDraftUseRawChanged(true)">
|
||||
Raw line
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
<input class="form-control form-control-sm font-monospace"
|
||||
@bind="_draftRawLine" @bind:event="oninput"
|
||||
placeholder="domain[,subnet|IP|interface[,local]]" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -166,6 +219,16 @@
|
||||
<input id="domain-add-dom" class="form-control form-control-sm"
|
||||
placeholder="e.g. home.arpa or #"
|
||||
@bind="_draftDomain" @bind:event="oninput" />
|
||||
<div class="ec-domain-rule-hint-row">
|
||||
<div class="ec-domain-rule-hint">Use <code>#</code> to take the search domain from resolv.conf.</div>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
|
||||
title="@HashDomainHelpText"
|
||||
aria-label="@HashDomainHelpText"
|
||||
@onclick="ShowHashDomainHelp">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="domain-add-scope">Applies to</label>
|
||||
@@ -181,8 +244,9 @@
|
||||
<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"
|
||||
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="domain-add-loc"
|
||||
@@ -197,18 +261,25 @@
|
||||
<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"
|
||||
placeholder="e.g. eth0 or br0"
|
||||
@bind="_draftMiddle" @bind:event="oninput" />
|
||||
</div>
|
||||
}
|
||||
else if (_draftScopeSelect == "unconditional")
|
||||
{
|
||||
<div class="form-check">
|
||||
<div class="form-check ec-domain-rule-local-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)
|
||||
Append <code>,local</code>
|
||||
</label>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
|
||||
title="@LocalSuffixHelpText"
|
||||
aria-label="@LocalSuffixHelpText"
|
||||
@onclick="ShowLocalSuffixHelp">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -230,7 +301,7 @@
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
@@ -354,6 +425,12 @@
|
||||
_draftUseRaw = useRaw;
|
||||
}
|
||||
|
||||
private void ShowDomainRuleEditorHelp() => NotificationService.ShowInfo(DomainRuleEditorHelpText);
|
||||
|
||||
private void ShowLocalSuffixHelp() => NotificationService.ShowInfo(LocalSuffixHelpText);
|
||||
|
||||
private void ShowHashDomainHelp() => NotificationService.ShowInfo(HashDomainHelpText);
|
||||
|
||||
private void OnDraftScopeAfterChange()
|
||||
{
|
||||
if (_draftScopeSelect == "unconditional")
|
||||
@@ -490,4 +567,13 @@
|
||||
await OnValuesChanged.InvokeAsync(next);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private const string DomainRuleEditorHelpText =
|
||||
"domain= sets the suffix dnsmasq uses for DHCP and local DNS naming. Guided mode builds the rule from fields; Raw line mode lets you enter the exact dnsmasq config line directly.";
|
||||
|
||||
private const string LocalSuffixHelpText =
|
||||
"Appending ,local behaves like a matching --local rule for this domain, making dnsmasq answer locally instead of forwarding it upstream. This is uncommon for an unconditional domain= rule.";
|
||||
|
||||
private const string HashDomainHelpText =
|
||||
"Use # as the domain value to take the search domain from resolv.conf instead of hard-coding a suffix in dnsmasq.";
|
||||
}
|
||||
|
||||
+90
-19
@@ -7,32 +7,22 @@
|
||||
background-color: var(--bs-body-bg, #fff);
|
||||
}
|
||||
|
||||
.ec-domain-rule-editor .ec-field-value-list,
|
||||
.ec-domain-rule-list {
|
||||
list-style: none;
|
||||
/* Keep <ol> markers (decimal) like the default multi-value editor; do not reset list-style here. */
|
||||
.ec-domain-rule-editor .ec-field-value-list {
|
||||
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 {
|
||||
.ec-domain-rule-row-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ec-domain-rule-row.ec-domain-rule-row-editing .ec-domain-rule-row-body {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@@ -52,13 +42,77 @@
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
}
|
||||
|
||||
.ec-domain-rule-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ec-domain-rule-toolbar__group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.ec-domain-rule-mode-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.2rem;
|
||||
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.ec-domain-rule-mode-btn {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ec-domain-rule-mode-btn:hover,
|
||||
.ec-domain-rule-mode-btn:focus-visible {
|
||||
color: #0f172a;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.ec-domain-rule-mode-btn--active,
|
||||
.ec-domain-rule-mode-btn--active:hover,
|
||||
.ec-domain-rule-mode-btn--active:focus-visible {
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, #0d6efd 0%, #0a58ca 100%);
|
||||
}
|
||||
|
||||
.ec-domain-rule-help {
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ec-domain-rule-help:hover,
|
||||
.ec-domain-rule-help:focus-visible {
|
||||
color: var(--bs-primary) !important;
|
||||
}
|
||||
|
||||
.ec-domain-rule-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.ec-domain-rule-editor .ec-field-value-list li.ec-multi-list-add-item .ec-domain-rule-actions .status-badge-inline,
|
||||
.ec-domain-rule-actions .status-badge-inline {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.ec-domain-rule-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -77,3 +131,20 @@
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
.ec-domain-rule-hint-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.ec-domain-rule-local-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.ec-domain-rule-local-check .form-check-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
+2
-34
@@ -38,42 +38,10 @@ else
|
||||
</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)
|
||||
else
|
||||
{
|
||||
<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>
|
||||
}
|
||||
<span class="text-muted">(none)</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
|
||||
+15
-15
@@ -49,22 +49,22 @@ else if (Descriptor.IsMultiValue)
|
||||
{
|
||||
@* 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="@customMultiActiveEditor">
|
||||
<CascadingValue Value="@IsEditMode">
|
||||
@customMulti(_multiContext)
|
||||
</CascadingValue>
|
||||
<li class="ec-field ec-field--multi-value @(customMultiActiveEditor ? "ec-field-editing" : "")">
|
||||
<div class="ec-field-label-row">
|
||||
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
|
||||
@if (!Descriptor.IsCapabilityDisabled)
|
||||
{
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@(customMultiActiveEditor ? _multiContext.RowSource : null)" OptionName="@Descriptor.OptionName" Value="@_multiContext.EffectiveItems"
|
||||
IsInteractive="@IsEditMode" ShowEditableBadge="true" HasPendingChange="@_multiContext.HasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@customMultiActiveEditor"
|
||||
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
|
||||
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
}
|
||||
</div>
|
||||
<CascadingValue Value="@customMultiActiveEditor">
|
||||
<CascadingValue Value="@IsEditMode">
|
||||
@customMulti(_multiContext)
|
||||
</CascadingValue>
|
||||
</span>
|
||||
@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" />
|
||||
}
|
||||
</CascadingValue>
|
||||
</li>
|
||||
}
|
||||
else if (_multiContext is not null)
|
||||
|
||||
+41
-39
@@ -19,47 +19,49 @@
|
||||
var rowValue = item.Value;
|
||||
|
||||
<li class="ec-multi-list-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-multi-list-row-with-controls" : "")" @key="anchorId">
|
||||
@if (_editingIndex == rowIndex && !itemReadonly)
|
||||
{
|
||||
<div class="ec-multi-list-row-controls">
|
||||
<div class="ec-multi-list-input-group">
|
||||
<input class="form-control form-control-sm ec-multi-list-input"
|
||||
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
<div class="ec-multi-list-row-body">
|
||||
@if (_editingIndex == rowIndex && !itemReadonly)
|
||||
{
|
||||
<div class="ec-multi-list-row-controls">
|
||||
<div class="ec-multi-list-input-group">
|
||||
<input class="form-control form-control-sm ec-multi-list-input"
|
||||
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
</div>
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
|
||||
Title="Apply change" AriaLabel="Apply change"
|
||||
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard edit" AriaLabel="Cancel edit"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</span>
|
||||
</div>
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
|
||||
Title="Apply change" AriaLabel="Apply change"
|
||||
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard edit" AriaLabel="Cancel edit"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ec-field-value-item">@rowValue</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ec-field-value-item">@rowValue</span>
|
||||
|
||||
<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>
|
||||
}
|
||||
<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>
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -13,14 +13,18 @@
|
||||
}
|
||||
|
||||
.ec-multi-list-row {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.ec-multi-list-row-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
min-height: 2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ec-multi-list-row.ec-multi-list-row-with-controls {
|
||||
.ec-multi-list-row.ec-multi-list-row-with-controls .ec-multi-list-row-body {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@@ -47,18 +51,18 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ec-multi-list-row .ec-field-value-item {
|
||||
.ec-multi-list-row-body .ec-field-value-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ec-multi-list-row .status-badge-inline {
|
||||
.ec-multi-list-row-body .status-badge-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ec-multi-list-row .form-control.ec-multi-list-input {
|
||||
.ec-multi-list-row-body .form-control.ec-multi-list-input {
|
||||
max-width: none;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
@@ -86,7 +90,7 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.ec-multi-list-add-item .status-badge-inline {
|
||||
.ec-multi-list-add-item > .ec-multi-list-row-controls > .status-badge-inline {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
+13
-40
@@ -2,8 +2,18 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<li class="ec-field">
|
||||
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Context.Descriptor.OptionName" Title="@LabelTooltip"><strong>@Context.Descriptor.OptionName:</strong></OptionHelpLabel>
|
||||
<li class="ec-field ec-field--multi-value @(Context.IsActiveEditor ? "ec-field-editing" : "")">
|
||||
<div class="ec-field-label-row">
|
||||
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Context.Descriptor.OptionName" Title="@LabelTooltip"><strong>@Context.Descriptor.OptionName:</strong></OptionHelpLabel>
|
||||
@if (Context.IsActiveEditor || Context.ShowEditableBadge)
|
||||
{
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@(Context.IsActiveEditor ? Context.RowSource : null)" 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" />
|
||||
}
|
||||
</div>
|
||||
@if (Context.IsActiveEditor)
|
||||
{
|
||||
<EffectiveConfigMultiValueListEditor
|
||||
@@ -16,11 +26,6 @@
|
||||
Behavior="@(MultiDesc?.Behavior)"
|
||||
Validator="@(MultiDesc?.Validator)"
|
||||
OnValuesChanged="@ItemsChanged" />
|
||||
<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
|
||||
{
|
||||
@@ -44,42 +49,10 @@
|
||||
</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>
|
||||
}
|
||||
<span class="text-muted">(none)</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
|
||||
@@ -230,6 +230,12 @@ else
|
||||
@onclick="OnManagedHeaderSaveAsync">
|
||||
<i class="bi bi-check-lg" aria-hidden="true"></i><span>Save</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn hosts-managed-action-btn hosts-managed-action-btn--cancel"
|
||||
title="Discard this row edit"
|
||||
@onclick="OnManagedHeaderCancelAsync">
|
||||
<i class="bi bi-x-lg" aria-hidden="true"></i><span>Cancel</span>
|
||||
</button>
|
||||
}
|
||||
else if (ManagedSelectionCount == 1)
|
||||
{
|
||||
@@ -865,6 +871,11 @@ else
|
||||
await TryFinishHostEditFromKeyboardAsync();
|
||||
}
|
||||
|
||||
private async Task OnManagedHeaderCancelAsync()
|
||||
{
|
||||
await DiscardCurrentEditAsync();
|
||||
}
|
||||
|
||||
private async Task OnManagedHeaderEditAsync()
|
||||
{
|
||||
DeactivateEffectiveConfigFieldIfActive();
|
||||
@@ -1057,6 +1068,11 @@ else
|
||||
|
||||
private void RevealHostFieldValidation() => _showHostFieldValidation = true;
|
||||
|
||||
private bool IsCurrentEditBlank =>
|
||||
string.IsNullOrWhiteSpace(_editAddress)
|
||||
&& ParseNamesList(_editNames).Count == 0
|
||||
&& string.IsNullOrWhiteSpace(_editInlineComment);
|
||||
|
||||
private void DeactivateEffectiveConfigFieldIfActive()
|
||||
{
|
||||
if (Session.Ui.ActiveFieldKey != null)
|
||||
@@ -1072,41 +1088,8 @@ else
|
||||
|
||||
private async Task OnInvalidEditDiscardAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_editingRowId) || _status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
|
||||
{
|
||||
_showInvalidHostEditModal = false;
|
||||
CancelEdit();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
var list = CloneEntries(CurrentManagedEntries).ToList();
|
||||
var entry = list.FirstOrDefault(MatchesEditingId);
|
||||
var wasNew = entry != null && entry.Id.StartsWith("new:", StringComparison.Ordinal);
|
||||
if (wasNew)
|
||||
{
|
||||
list.Remove(entry!);
|
||||
Session.Draft.SetManagedHostsDraft(
|
||||
CloneEntries(_baselineManagedEntries),
|
||||
list,
|
||||
_status.ManagedHostsFilePath);
|
||||
RebuildManagedOverlay();
|
||||
InvalidateVisibleGroupsCache();
|
||||
}
|
||||
|
||||
_showInvalidHostEditModal = false;
|
||||
var prevId = _editingRowId;
|
||||
CancelEdit();
|
||||
if (wasNew)
|
||||
_selectedManagedRowIds.Clear();
|
||||
else
|
||||
{
|
||||
_selectedManagedRowIds.Clear();
|
||||
if (!string.IsNullOrEmpty(prevId))
|
||||
_selectedManagedRowIds.Add(prevId);
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
await DiscardCurrentEditAsync();
|
||||
}
|
||||
|
||||
private async Task OnEditFieldKeyDown(KeyboardEventArgs e)
|
||||
@@ -1131,6 +1114,12 @@ else
|
||||
if (_showInvalidHostEditModal || string.IsNullOrEmpty(_editingRowId))
|
||||
return;
|
||||
|
||||
if (IsCurrentEditBlank)
|
||||
{
|
||||
await DiscardCurrentEditAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsCurrentEditValid)
|
||||
await CommitAndEndInlineEditAsync();
|
||||
else
|
||||
@@ -1168,6 +1157,10 @@ else
|
||||
{
|
||||
try
|
||||
{
|
||||
var recentCueInteraction = await _hostsTableScrollCueModule.InvokeAsync<bool>("wasRecentCueInteraction");
|
||||
if (recentCueInteraction)
|
||||
return;
|
||||
|
||||
outside = await _hostsTableScrollCueModule.InvokeAsync<bool>("isFocusOutsideElement", _editingRowRef);
|
||||
}
|
||||
catch (Exception ex) when (ex is JSDisconnectedException or JSException or ObjectDisposedException or TaskCanceledException)
|
||||
@@ -1178,6 +1171,12 @@ else
|
||||
|
||||
if (outside)
|
||||
{
|
||||
if (IsCurrentEditBlank)
|
||||
{
|
||||
await DiscardCurrentEditAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsCurrentEditValid)
|
||||
await CommitAndEndInlineEditAsync();
|
||||
else
|
||||
@@ -1220,6 +1219,41 @@ else
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task DiscardCurrentEditAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_editingRowId) || _status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
|
||||
{
|
||||
CancelEdit();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
var list = CloneEntries(CurrentManagedEntries).ToList();
|
||||
var entry = list.FirstOrDefault(MatchesEditingId);
|
||||
var wasNew = entry != null && entry.Id.StartsWith("new:", StringComparison.Ordinal);
|
||||
var prevId = _editingRowId;
|
||||
|
||||
if (wasNew)
|
||||
{
|
||||
list.Remove(entry!);
|
||||
Session.Draft.SetManagedHostsDraft(
|
||||
CloneEntries(_baselineManagedEntries),
|
||||
list,
|
||||
_status.ManagedHostsFilePath);
|
||||
RebuildManagedOverlay();
|
||||
InvalidateVisibleGroupsCache();
|
||||
}
|
||||
|
||||
CancelEdit();
|
||||
_showInvalidHostEditModal = false;
|
||||
_selectedManagedRowIds.Clear();
|
||||
if (!wasNew && !string.IsNullOrEmpty(prevId))
|
||||
_selectedManagedRowIds.Add(prevId);
|
||||
_selectionAnchorRowId = !wasNew ? prevId : null;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private bool MatchesEditingId(HostEntry e)
|
||||
{
|
||||
var id = string.IsNullOrEmpty(e.Id) ? $"managed:{e.LineNumber}" : e.Id;
|
||||
|
||||
@@ -376,7 +376,7 @@
|
||||
/* Fixed column for actions avoids path/subtitle reflow when buttons show or hide. */
|
||||
.hosts-group-header__path-row--with-managed-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--hosts-managed-actions-col, 9.75rem);
|
||||
grid-template-columns: minmax(0, 1fr) var(--hosts-managed-actions-col, 13.5rem);
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
@@ -477,6 +477,19 @@
|
||||
border-color: rgba(8, 66, 152, 0.92);
|
||||
}
|
||||
|
||||
.hosts-managed-action-btn--cancel {
|
||||
color: #334155;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
border-color: rgba(148, 163, 184, 0.38);
|
||||
}
|
||||
|
||||
.hosts-managed-action-btn--cancel:hover,
|
||||
.hosts-managed-action-btn--cancel:focus-visible {
|
||||
color: #0f172a;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||
border-color: rgba(100, 116, 139, 0.42);
|
||||
}
|
||||
|
||||
.hosts-managed-action-btn--edit {
|
||||
color: #0f172a;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
@@ -589,7 +602,7 @@
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.hosts-group-header__path-row--with-managed-actions {
|
||||
--hosts-managed-actions-col: 9rem;
|
||||
--hosts-managed-actions-col: 12rem;
|
||||
}
|
||||
|
||||
.hosts-group-header__managed-actions {
|
||||
|
||||
@@ -10,10 +10,16 @@ const POLL_MS = 100;
|
||||
const BTN_LEFT_CLASS = 'app-table-cue-btn--left';
|
||||
const BTN_RIGHT_CLASS = 'app-table-cue-btn--right';
|
||||
const CUE_BTN_CLASS = 'app-table-cue-btn';
|
||||
const RECENT_CUE_INTERACTION_MS = 300;
|
||||
|
||||
let pollId = null;
|
||||
let resizeListener = null;
|
||||
const scrollListeners = new WeakMap();
|
||||
let lastCueInteractionAt = 0;
|
||||
|
||||
function markCueInteraction() {
|
||||
lastCueInteractionAt = Date.now();
|
||||
}
|
||||
|
||||
function applyScrollCue(scrollEl, hostEl) {
|
||||
if (!scrollEl || !hostEl) return;
|
||||
@@ -58,7 +64,9 @@ function ensureCueButtons(hostEl) {
|
||||
leftBtn.type = 'button';
|
||||
leftBtn.className = CUE_BTN_CLASS + ' ' + BTN_LEFT_CLASS;
|
||||
leftBtn.setAttribute('aria-label', 'Scroll table left');
|
||||
leftBtn.addEventListener('pointerdown', markCueInteraction);
|
||||
leftBtn.addEventListener('click', function () {
|
||||
markCueInteraction();
|
||||
scrollToSide(hostEl.querySelector(WRAPPER_SELECTOR), 'left');
|
||||
});
|
||||
hostEl.appendChild(leftBtn);
|
||||
@@ -69,7 +77,9 @@ function ensureCueButtons(hostEl) {
|
||||
rightBtn.type = 'button';
|
||||
rightBtn.className = CUE_BTN_CLASS + ' ' + BTN_RIGHT_CLASS;
|
||||
rightBtn.setAttribute('aria-label', 'Scroll table right');
|
||||
rightBtn.addEventListener('pointerdown', markCueInteraction);
|
||||
rightBtn.addEventListener('click', function () {
|
||||
markCueInteraction();
|
||||
scrollToSide(hostEl.querySelector(WRAPPER_SELECTOR), 'right');
|
||||
});
|
||||
hostEl.appendChild(rightBtn);
|
||||
@@ -170,3 +180,7 @@ export function isFocusOutsideElement(container) {
|
||||
if (!active) return true;
|
||||
return !container.contains(active);
|
||||
}
|
||||
|
||||
export function wasRecentCueInteraction() {
|
||||
return Date.now() - lastCueInteractionAt <= RECENT_CUE_INTERACTION_MS;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user