Files
dnsmasq-webui/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/ConfigOptionHelpModal.razor
T
alex 2e713f0e3c 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
2026-04-05 14:46:17 +10:00

188 lines
6.1 KiB
Plaintext

@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Extensions.DependencyInjection
@using DnsmasqWebUI.Extensions.Interop
@using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop
@using System.Text.Json.Serialization
@inject IHttpClientFactory HttpFactory
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
@if (Visible)
{
<div class="config-option-help-popover" id="@_popoverId" style="@_positionStyle" @onmouseenter="OnModalMouseEnter" @onmouseleave="OnModalMouseLeave">
<div class="config-option-help-popover-content">
<div class="config-option-help-popover-header">
<span class="config-option-help-popover-title">@Title</span>
</div>
<div class="config-option-help-popover-body">
@if (_loading)
{
<p class="text-muted small mb-0">Loading…</p>
}
else if (_error != null)
{
<p class="text-danger small mb-0">@_error</p>
}
else if (_helpHtml != null)
{
<div class="option-help">@((MarkupString)_helpHtml)</div>
}
</div>
</div>
</div>
}
@code {
[Parameter] public string? HelpKey { get; set; }
[Parameter] public string? AnchorId { get; set; }
[Parameter] public bool Visible { get; set; }
[Parameter] public string Title { get; set; } = "Option help";
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnMouseEnterModal { get; set; }
[Parameter] public EventCallback OnMouseLeaveModal { get; set; }
private string? _helpHtml;
private string? _error;
private bool _loading;
private string? _lastKey;
private string _positionStyle = "";
private readonly string _popoverId = "config-option-help-popover-" + Guid.NewGuid().ToString("N")[..8];
private DotNetObjectReference<ConfigOptionHelpModal>? _dotNetRef;
private bool _scrollListenerAdded;
private bool _mouseOverModal;
private IJSObjectReference? _jsModule;
private bool _moduleLoaded;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_jsModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/config-option-help.js");
_moduleLoaded = _jsModule != null;
}
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
{
_scrollListenerAdded = true;
_dotNetRef = DotNetObjectReference.Create(this);
await _jsModule.InvokeVoidAsyncSafe("addScrollCloseListener", _dotNetRef);
}
else if (!Visible && _scrollListenerAdded && _jsModule != null)
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
}
}
[JSInvokable]
public async Task OnScrollClose()
{
await PositionUnderAnchorAsync();
StateHasChanged();
if (!_mouseOverModal)
await OnClose.InvokeAsync();
}
public async ValueTask DisposeAsync()
{
try
{
if (_scrollListenerAdded && _jsModule != null)
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
}
}
finally
{
_dotNetRef?.Dispose();
_dotNetRef = null;
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
}
protected override async Task OnParametersSetAsync()
{
if (!Visible || string.IsNullOrEmpty(HelpKey))
{
_helpHtml = null;
_error = null;
_loading = false;
_positionStyle = "";
_mouseOverModal = false;
return;
}
if (HelpKey == _lastKey && _helpHtml != null)
{
await PositionUnderAnchorAsync();
return;
}
_lastKey = HelpKey;
_loading = true;
_error = null;
_helpHtml = null;
_positionStyle = "visibility: hidden;"; // avoid flash before position is set
StateHasChanged();
try
{
var http = HttpFactory.CreateClient(ServiceCollectionExtensions.DnsmasqApiClientName);
var response = await http.GetAsync($"option-help/{HelpKey}.html");
if (response.IsSuccessStatusCode)
_helpHtml = await response.Content.ReadAsStringAsync();
else
_error = "Help not available for this option.";
}
catch
{
_error = "Could not load help.";
}
finally
{
_loading = false;
await PositionUnderAnchorAsync();
StateHasChanged();
}
}
private async Task PositionUnderAnchorAsync()
{
if (string.IsNullOrEmpty(AnchorId) || _jsModule == null) return;
var rect = await _jsModule.InvokeAsyncSafe<BoundingRect?>("getAnchorRect", AnchorId);
if (rect is { } r)
{
var gap = 4;
var topPx = r.Bottom + gap;
var leftPx = r.Left;
_positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;";
}
else
{
_positionStyle = "visibility: visible;";
}
}
private async Task OnModalMouseEnter()
{
_mouseOverModal = true;
await OnMouseEnterModal.InvokeAsync();
}
private async Task OnModalMouseLeave()
{
_mouseOverModal = false;
await OnMouseLeaveModal.InvokeAsync();
}
private sealed record BoundingRect(
[property: JsonPropertyName("top")] double Top,
[property: JsonPropertyName("left")] double Left,
[property: JsonPropertyName("bottom")] double Bottom,
[property: JsonPropertyName("width")] double Width,
[property: JsonPropertyName("height")] double Height);
}