diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CopyableCodeLine.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CopyableCodeLine.razor new file mode 100644 index 0000000..febb33c --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CopyableCodeLine.razor @@ -0,0 +1,27 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig +@inject IJSRuntime JSRuntime + +
+ @Text + +
+ +@code { + [Parameter] public string Text { get; set; } = ""; + [Parameter] public string? CssClass { get; set; } + [Parameter] public string? CodeCssClass { get; set; } + [Parameter] public string CopyTitle { get; set; } = "Copy"; + + private async Task CopyAsync() + { + if (string.IsNullOrEmpty(Text)) return; + try + { + await JSRuntime.InvokeVoidAsync("copyToClipboard", Text); + } + catch + { + // Clipboard not available or failed + } + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/FlagValueDisplay.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/FlagValueDisplay.razor index 2841b97..a1bf030 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/FlagValueDisplay.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/FlagValueDisplay.razor @@ -1,25 +1,21 @@ @namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays @using DnsmasqWebUI.Models.Dnsmasq +@inherits EffectiveConfigValueDisplayBase -@* Reusable value-only display for boolean/flag options. When active editor: checkbox (unless readonly); View: "Enabled" / "Disabled". *@ +@* Reusable value-only display for boolean/flag options. When active editor: checkbox (unless readonly); View: "Enabled" / "Disabled". Pending value shown until save. *@ @if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true) { - var isSet = Descriptor.GetValue() is bool b && b; + var isSet = GetEffectiveDisplayValue() is bool b && b; } else { - @(Descriptor.GetValue() is bool isSet && isSet ? "Enabled" : "Disabled") + @(GetEffectiveDisplayValue() is bool isSet && isSet ? "Enabled" : "Disabled") } @code { - [Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!; - [CascadingParameter] public bool IsActiveEditor { get; set; } - [CascadingParameter] public EventCallback OnCommitRequested { get; set; } - [Parameter] public EventCallback ValueChanged { get; set; } - private async Task OnCheckedChange(ChangeEventArgs e) { - await ValueChanged.InvokeAsync(e.Value is true); + await NotifyValueChanged(e.Value is true); } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/IntegerValueDisplay.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/IntegerValueDisplay.razor index 7289224..2609e4d 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/IntegerValueDisplay.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/IntegerValueDisplay.razor @@ -1,16 +1,17 @@ @namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays @using DnsmasqWebUI.Models.Dnsmasq +@inherits EffectiveConfigValueDisplayBase -@* Value-only display for int? options. When active editor: number input (unless readonly); View: value + optional suffix/unit. Metadata from EffectiveIntegerConfigFieldDescriptor. *@ +@* Value-only display for int? options. Pending value shown until save. *@ @if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true) { - var current = Descriptor.GetValue() is int i ? i : DefaultValue; + var current = GetEffectiveDisplayValue() is int i ? i : DefaultValue; } else { - var v = Descriptor.GetValue() is int n ? n : (int?)null; + var v = GetEffectiveDisplayValue() is int n ? n : (int?)null; if (v is null) { @("(not set)") @@ -24,11 +25,6 @@ else } @code { - [Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!; - [CascadingParameter] public bool IsActiveEditor { get; set; } - [CascadingParameter] public EventCallback OnCommitRequested { get; set; } - [Parameter] public EventCallback ValueChanged { get; set; } - private EffectiveIntegerConfigFieldDescriptor? IntDescriptor => Descriptor as EffectiveIntegerConfigFieldDescriptor; private string? ViewSuffix => IntDescriptor?.ViewSuffix; private string? Unit => IntDescriptor?.Unit; @@ -41,6 +37,6 @@ else int? value = null; if (int.TryParse(e.Value?.ToString(), out var num) && num >= Min && num <= Max) value = num; - await ValueChanged.InvokeAsync(value); + await NotifyValueChanged(value); } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/LocalServiceDisplay.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/LocalServiceDisplay.razor index 0b0cc9d..debb55b 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/LocalServiceDisplay.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/LocalServiceDisplay.razor @@ -1,10 +1,11 @@ @namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays @using DnsmasqWebUI.Models.Dnsmasq +@inherits EffectiveConfigValueDisplayBase -@* local-service: net | host. View: (not set) / net / host. When active editor: dropdown. *@ +@* local-service: net | host. Pending value shown until save. *@ @if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true) { - @@ -12,20 +13,15 @@ } else { - var v = Descriptor.GetValue() as string; + var v = GetEffectiveDisplayValue() as string; @(string.IsNullOrEmpty(v) ? "(not set)" : (v.Trim().ToLowerInvariant() is "net" or "host" ? v.Trim() : v)) } @code { - [Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!; - [CascadingParameter] public bool IsActiveEditor { get; set; } - [CascadingParameter] public EventCallback OnCommitRequested { get; set; } - [Parameter] public EventCallback ValueChanged { get; set; } - private async Task OnChange(ChangeEventArgs e) { var v = e.Value?.ToString()?.Trim(); var value = string.IsNullOrEmpty(v) ? null : (v == "net" || v == "host" ? v : null); - await ValueChanged.InvokeAsync(value); + await NotifyValueChanged(value); } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/LogQueriesDisplay.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/LogQueriesDisplay.razor index 12f9505..a8f3e4c 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/LogQueriesDisplay.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/LogQueriesDisplay.razor @@ -1,5 +1,6 @@ @namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays @using DnsmasqWebUI.Models.Dnsmasq +@inherits EffectiveConfigValueDisplayBase @* log-queries: optional value extra|proto|auth. View: Off / On / On (extra|proto|auth). When active editor: dropdown. *@ @if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true) @@ -18,14 +19,9 @@ else } @code { - [Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!; - [CascadingParameter] public bool IsActiveEditor { get; set; } - [CascadingParameter] public EventCallback OnCommitRequested { get; set; } - [Parameter] public EventCallback ValueChanged { get; set; } - private string GetEditValue() { - var v = Descriptor.GetValue() as string; + var v = GetEffectiveDisplayValue() as string; if (string.IsNullOrEmpty(v)) return ""; var lower = v.Trim().ToLowerInvariant(); if (lower is "extra" or "proto" or "auth") return lower; @@ -34,7 +30,7 @@ else private string GetViewText() { - var v = Descriptor.GetValue() as string; + var v = GetEffectiveDisplayValue() as string; if (string.IsNullOrEmpty(v)) return "Off"; var lower = v.Trim().ToLowerInvariant(); return lower switch @@ -58,6 +54,6 @@ else "auth" => "auth", _ => null }; - await ValueChanged.InvokeAsync(newValue); + await NotifyValueChanged(newValue); } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/PortValueDisplay.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/PortValueDisplay.razor index c9b9e50..bf23f42 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/PortValueDisplay.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/PortValueDisplay.razor @@ -1,28 +1,24 @@ @namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays @using DnsmasqWebUI.Models.Dnsmasq +@inherits EffectiveConfigValueDisplayBase -@* Value-only display for the port option. IsActiveEditor is cascaded from FieldDisplay (true only for the single active field in edit mode). When active editor and not readonly: number input; else: view text. *@ +@* Value-only display for the port option. Pending value shown until save. *@ @if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true) { - var port = Descriptor.GetValue() is int p ? p : 53; + var port = GetEffectiveDisplayValue() is int p ? p : 53; } else { - @(Descriptor.GetValue() is int port ? $"{port} (default DNS port)" : "(not set)") + @(GetEffectiveDisplayValue() is int port ? $"{port} (default DNS port)" : "(not set)") } @code { - [Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!; - [CascadingParameter] public bool IsActiveEditor { get; set; } - [CascadingParameter] public EventCallback OnCommitRequested { get; set; } - [Parameter] public EventCallback ValueChanged { get; set; } - private async Task OnPortInput(ChangeEventArgs e) { if (int.TryParse(e.Value?.ToString(), out var port) && port >= 1 && port <= 65535) - await ValueChanged.InvokeAsync(port); + await NotifyValueChanged(port); else - await ValueChanged.InvokeAsync(null); + await NotifyValueChanged(null); } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor index 2a10ffd..c32dde4 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor @@ -6,7 +6,7 @@ @using DnsmasqWebUI.Infrastructure.Services.Abstractions @inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry -@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. *@ +@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. Pending value shown until save. *@ @if (_customFragment is { } fragment) { var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName); @@ -14,10 +14,10 @@ var showEditableBadge = !isReadonly; var isActiveEditor = IsEditMode && IsActive; var fragmentWithCallback = isActiveEditor ? _fragmentWithCallback : null; + var effectiveValue = _effectiveValue ?? Descriptor.GetValue();
  • - @Descriptor.OptionName: + @Descriptor.OptionName: - @* IsActiveEditor: cascaded from here (true only for this field when it's the active editor). Section cascades IsEditMode + ActiveFieldKey; Panel passes IsActive=(ActiveFieldKey==fieldKey); we derive isActiveEditor = IsEditMode && IsActive and cascade that bool so fragment children (e.g. PortValueDisplay) know whether to show the input. *@ @if (isActiveEditor) {
    @@ -34,20 +34,10 @@ @fragment(Descriptor) } - @if (isReadonly) - { - @if (IsEditMode) - { - - } - else - { - readonly - } - } + @if (showEditableBadge) { - + }
  • @@ -55,11 +45,11 @@ else if (Descriptor.IsMultiValue) { var items = Descriptor.GetItems(); - + } else { - var value = Descriptor.GetValue(); + var value = _effectiveValue ?? Descriptor.GetValue(); var displayValue = value == null ? "(not set)" : (value is IReadOnlyList list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString()); var isReadonly = Descriptor.GetSource()?.IsReadOnly == true; var showReadonlyHint = IsEditMode && isReadonly; @@ -67,16 +57,18 @@ else @if (showReadonlyHint) {
  • - +
  • } else { - +
  • + +
  • } } @@ -87,10 +79,7 @@ else [Parameter] public string? ActiveFieldKey { get; set; } [Parameter] public EventCallback OnActivateEdit { get; set; } [Parameter] public EventCallback OnEditCommitted { get; set; } - [Parameter] public EventCallback OnReadonlyBadgeClicked { get; set; } - [Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; } - [Parameter] public EventCallback OnOptionHelpRequested { get; set; } - [CascadingParameter] public string? ManagedFilePath { get; set; } + [CascadingParameter] public IEffectiveConfigEditSession? Session { get; set; } private readonly string _anchorId = "ec-help-" + Guid.NewGuid().ToString("N")[..8]; private readonly string _readonlyBadgeAnchorId = "ec-readonly-" + Guid.NewGuid().ToString("N")[..8]; @@ -98,6 +87,8 @@ else private RenderFragment? _fragmentWithCallback; private string? _labelTooltip; private string? _optionHelpKey; + private object? _effectiveValue; + private bool _hasPendingChange; private object? _valueAtActivation; private object? _draftValue; private bool _wasActive; @@ -108,8 +99,13 @@ else _fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create(this, HandleValueChanged)); _labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName); _optionHelpKey = DnsmasqOptionTooltips.GetOptionHelpKey(Descriptor.OptionName); + var pending = Session?.PendingChanges.FirstOrDefault(c => + string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) && + string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase)); + _hasPendingChange = pending != null; + _effectiveValue = pending?.NewValue ?? Descriptor.GetValue(); if (!_wasActive && IsActive) - _valueAtActivation = Descriptor.GetValue(); + _valueAtActivation = _effectiveValue; if (_wasActive && !IsActive) _ = InvokeAsync(CommitIfChangedAsync); _wasActive = IsActive; @@ -128,21 +124,20 @@ else private async Task CommitIfChangedAsync() { - var newValue = _draftValue ?? Descriptor.GetValue(); + var newValue = _draftValue ?? _effectiveValue ?? Descriptor.GetValue(); if (!Equals(_valueAtActivation, newValue)) { var source = Descriptor.GetSource(); - await OnEditCommitted.InvokeAsync(new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath)); + var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath); + if (Ui != null) + await Ui.CommitFieldAsync(args); + else if (OnEditCommitted.HasDelegate) + await OnEditCommitted.InvokeAsync(args); } _draftValue = null; } - private async Task RaiseReadonlyBadgeClicked() - { - var source = Descriptor.GetSource(); - if (source == null) return; - await OnReadonlyBadgeClicked.InvokeAsync(new ReadonlyBadgeClickedEventArgs(_readonlyBadgeAnchorId, source, Descriptor.OptionName, Descriptor.GetValue())); - } + [CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; } private void HandleDefaultValueChanged(string value) { @@ -156,11 +151,4 @@ else await CommitIfChangedAsync(); } - private async Task RaiseOptionHelpRequested() - { - if (string.IsNullOrEmpty(_optionHelpKey)) return; - await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = _optionHelpKey, OptionLabel = Descriptor.OptionName, AnchorId = _anchorId }); - } - - private async Task RaiseLabelMouseLeave() => await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = _anchorId, IsLabelMouseLeave = true }); } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueItem.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueItem.razor index cf2d126..dd897f8 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueItem.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueItem.razor @@ -4,18 +4,8 @@ @Value - @if (Source?.IsReadOnly == true) - { - @if (IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId) && OnReadonlyBadgeClick.HasDelegate) - { - - } - else - { - readonly - } - } - else if (IsEditMode) + + @if (Source?.IsReadOnly != true && IsEditMode) { editable } @@ -24,8 +14,7 @@ @code { [Parameter] public string Value { get; set; } = ""; [Parameter] public ConfigValueSource? Source { get; set; } + [Parameter] public string OptionName { get; set; } = ""; [Parameter] public bool IsEditMode { get; set; } [Parameter] public string? ReadonlyBadgeAnchorId { get; set; } - [Parameter] public EventCallback OnReadonlyBadgeClick { get; set; } - [Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueRow.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueRow.razor index 4d91a4d..b75cc1f 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueRow.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueRow.razor @@ -3,7 +3,7 @@ @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
  • - @Label: + @Label: @if (IsActiveEditor) { @@ -18,9 +18,8 @@
      @foreach (var (item, index) in Items!.Select((item, index) => (item, index))) { - var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : null; - var args = new ReadonlyBadgeClickedEventArgs(anchorId ?? "", item.Source!, Label, item.Value); -
    1. + var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : ""; +
    2. }
    } @@ -42,24 +41,9 @@ [Parameter] public bool IsActiveEditor { get; set; } [Parameter] public bool IsEditMode { get; set; } [Parameter] public string? ReadonlyBadgeAnchorIdBase { get; set; } - [Parameter] public EventCallback OnReadonlyBadgeClicked { get; set; } - [Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; } - [Parameter] public EventCallback OnOptionHelpRequested { get; set; } private string _editLines = ""; - private async Task RaiseOptionHelpRequested() - { - if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return; - await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId }); - } - - private async Task RaiseLabelMouseLeave() - { - if (string.IsNullOrEmpty(OptionHelpAnchorId)) return; - await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true }); - } - protected override void OnParametersSet() { if (Items != null && Items.Count > 0) diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigOptionItem.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigOptionItem.razor index 703f267..6b6a868 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigOptionItem.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigOptionItem.razor @@ -2,95 +2,45 @@ @using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig -@if (SuppressOuterLi) +@Label: +@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true) { - @Label: - @if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true) - { - - - @if (ShowEditableBadge) - { - - } - - } - else - { - - @DisplayValue - @if (Source?.IsReadOnly == true) - { - @if (!string.IsNullOrEmpty(ReadonlyBadgeAnchorId) && OnReadonlyBadgeClick.HasDelegate) - { - - } - else - { - readonly - } - } - @if (ShowEditableBadge && Source?.IsReadOnly != true) - { - - } - - } + + + @if (ShowEditableBadge) + { + + } + } else { -
  • - @Label: - @if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true) + + @DisplayValue + + @if (ShowEditableBadge && Source?.IsReadOnly != true) { - - - @if (ShowEditableBadge) - { - - } - + } - else - { - - @DisplayValue - @if (Source?.IsReadOnly == true) - { - @if (!string.IsNullOrEmpty(ReadonlyBadgeAnchorId) && OnReadonlyBadgeClick.HasDelegate) - { - - } - else - { - readonly - } - } - @if (ShowEditableBadge && Source?.IsReadOnly != true) - { - - } - - } -
  • +
    } @code { [Parameter] public string Label { get; set; } = ""; [Parameter] public string DisplayValue { get; set; } = ""; [Parameter] public ConfigValueSource? Source { get; set; } - /// When true, render only the inner content (no outer li); used when parent wraps with ReadonlyEditHint. - [Parameter] public bool SuppressOuterLi { get; set; } /// Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips). [Parameter] public string? LabelTooltip { get; set; } [Parameter] public string? OptionHelpKey { get; set; } [Parameter] public string? OptionHelpAnchorId { get; set; } - /// When set with OnReadonlyBadgeClick, the readonly badge is rendered as a button with this id so the parent can open a popover under it. + /// When set in edit mode, the readonly badge is rendered as a button with this id so the popover can anchor to it. [Parameter] public string? ReadonlyBadgeAnchorId { get; set; } - [Parameter] public EventCallback OnReadonlyBadgeClick { get; set; } - [Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; } + /// Raw value for the readonly popover override line (e.g. from descriptor); falls back to DisplayValue. + [Parameter] public object? RawValue { get; set; } /// When true, show an 'editable' badge next to non-readonly fields; clicking it activates edit for this field. [Parameter] public bool ShowEditableBadge { get; set; } + /// When true, badge shows "pending" and warning style to indicate this field has an unsaved change. + [Parameter] public bool HasPendingChange { get; set; } [Parameter] public EventCallback OnActivateEdit { get; set; } /// When true, add ec-field-editing class and show input when DisplayMode is Edit. [Parameter] public bool IsActive { get; set; } @@ -99,19 +49,6 @@ else /// When Edit and not readonly: raised when the input loses focus; argument is the current value. [Parameter] public EventCallback OnBlur { get; set; } [CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View; - [Parameter] public EventCallback OnOptionHelpRequested { get; set; } - - private async Task RaiseOptionHelpRequested() - { - if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return; - await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId }); - } - - private async Task RaiseLabelMouseLeave() - { - if (string.IsNullOrEmpty(OptionHelpAnchorId)) return; - await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true }); - } private string? _editValue; diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigPanels.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigPanels.razor new file mode 100644 index 0000000..d0759a0 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigPanels.razor @@ -0,0 +1,50 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.Dnsmasq +@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Infrastructure.Helpers.Config +@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry + +
    + @{ + var views = EffectiveConfigViews.GetViewsForContext(Context); + var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry); + var hasSearch = !string.IsNullOrWhiteSpace(SearchTerm); + var term = SearchTerm?.Trim() ?? ""; + } + @foreach (var view in views) + { + if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors)) + continue; + var sectionDescriptors = hasSearch + ? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList() + : contextDescriptors.ToList(); + if (sectionDescriptors.Count == 0) + continue; + var isOpen = hasSearch || IsSectionOpen(view.SectionId); + + } +
    + +@code { + [Parameter] public DnsmasqServiceStatus? Status { get; set; } + [Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All; + [Parameter] public string SearchTerm { get; set; } = ""; + [Parameter] public HashSet OpenSectionIds { get; set; } = new(); + [Parameter] public EventCallback OnSectionToggle { get; set; } + [Parameter] public bool IsEditMode { get; set; } + [Parameter] public string? ActiveFieldKey { get; set; } + [Parameter] public EventCallback OnActivateField { get; set; } + [Parameter] public EventCallback OnFieldEditCommitted { get; set; } + + private bool IsSectionOpen(string id) => OpenSectionIds.Contains(id); + + private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term) + { + if (string.IsNullOrEmpty(term)) return true; + var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim(); + return searchable.Contains(term, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSaveFlow.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSaveFlow.razor new file mode 100644 index 0000000..59e722d --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSaveFlow.razor @@ -0,0 +1,33 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.Dnsmasq +@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Infrastructure.Services.Abstractions + +@if (ShowModal) +{ + +} + +@code { + [Parameter] public bool ShowModal { get; set; } + [Parameter] public IReadOnlyList PendingChanges { get; set; } = null!; + [Parameter] public DnsmasqServiceStatus? Status { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + [Parameter] public EventCallback OnSaveCompleted { get; set; } + [Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!; + + private async Task HandleSave() + { + if (Session.PendingChanges.Count == 0) return; + try + { + await Session.ApplyAsync(); + await OnClose.InvokeAsync(); + await OnSaveCompleted.InvokeAsync(); + } + catch (Exception) + { + // TODO: show error to user (e.g. toast or modal message) + } + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSaveModal.razor.css b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSaveModal.razor.css new file mode 100644 index 0000000..72f2530 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSaveModal.razor.css @@ -0,0 +1,9 @@ +/* Ensure the dialog stacks above the backdrop when both are siblings inside .modal */ +.modal-dialog { + position: relative; + z-index: 1056; +} + +.modal-backdrop { + z-index: 1050; +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor index 5563fa3..d94cfeb 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor @@ -3,88 +3,26 @@ @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Infrastructure.Helpers.Config @using DnsmasqWebUI.Infrastructure.Services.Abstractions -@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry -@inject DnsmasqWebUI.Infrastructure.Services.Abstractions.IDnsmasqConfigService ConfigService +@inject IEffectiveConfigEditSession Session @inject IJSRuntime JSRuntime -@implements IAsyncDisposable @if (Status != null) {
    - - -
    - @if (ShowSearchBox) - { - - } -
    - - - @if (_isEditMode) - { - - @if (_pendingChanges.Count > 0) - { - @_pendingChanges.Count change(s) - } - - } - else - { - - } -
    -
    - - - -
    - @{ - var views = EffectiveConfigViews.GetViewsForContext(Context); - var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry); - var hasSearch = !string.IsNullOrWhiteSpace(_searchTerm); - var term = _searchTerm?.Trim() ?? ""; - } - @foreach (var view in views) - { - if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors)) - continue; - var sectionDescriptors = hasSearch - ? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList() - : contextDescriptors.ToList(); - if (sectionDescriptors.Count == 0) - continue; - var isOpen = hasSearch ? true : IsOpen(view.SectionId); - - } -
    + + + + + + -
    - @if (_showSaveModal) - { - - } +
    } @@ -92,139 +30,95 @@ [Parameter] public DnsmasqServiceStatus? Status { get; set; } [Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All; [Parameter] public bool ShowSearchBox { get; set; } = true; - /// Raised after pending changes were successfully written to managed config (so the page can refresh status). [Parameter] public EventCallback OnSaveCompleted { get; set; } private string _searchTerm = ""; private HashSet _openPanels = new(); + private bool _showSaveModal; + private string? _optionHelpKey; private string? _optionHelpAnchorId; private string _optionHelpTitle = "Option help"; private bool _showOptionHelp; - private CancellationTokenSource? _closeCts; - - private bool _isEditMode; - private string? _activeFieldKey; - private readonly List _pendingChanges = new(); - private bool _showSaveModal; + private OptionHelpController? _optionHelpControllerRef; private bool _showReadonlyPopover; + private ReadonlyPopoverController? _readonlyPopoverControllerRef; private string? _readonlyPopoverAnchorId; private ConfigValueSource? _readonlyPopoverSource; private string? _readonlyPopoverOptionName; private object? _readonlyPopoverValue; - private CancellationTokenSource? _readonlyCloseCts; - private bool IsOpen(string id) => _openPanels.Contains(id); + private EffectiveConfigUiContext? _uiContext; + + protected override void OnInitialized() + { + _uiContext = new EffectiveConfigUiContext + { + RequestOptionHelpAsync = HandleOptionHelpRequested, + RequestReadonlyPopoverAsync = HandleReadonlyBadgeClicked, + ScheduleReadonlyPopoverCloseAsync = () => + { + _readonlyPopoverControllerRef?.ScheduleCloseFromBadgeLeave(); + return Task.CompletedTask; + }, + ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; }, + CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; } + }; + } + + private async Task OnSearchTermChanged(string value) + { + _searchTerm = value ?? ""; + await InvokeAsync(StateHasChanged); + } + + private void CloseOptionHelp() + { + _showOptionHelp = false; + StateHasChanged(); + } private async Task HandleOptionHelpRequested(ConfigOptionHelpRequestEventArgs args) { if (args.IsLabelMouseLeave) { if (args.AnchorId == _optionHelpAnchorId) - ScheduleClose(); + _optionHelpControllerRef?.ScheduleCloseFromLabelLeave(); + await InvokeAsync(StateHasChanged); return; } _optionHelpKey = args.HelpKey; _optionHelpTitle = args.OptionLabel; _optionHelpAnchorId = args.AnchorId; _showOptionHelp = true; - CancelCloseTimer(); await InvokeAsync(StateHasChanged); } - private void CancelCloseTimer() + private void CloseReadonlyPopover() { - _closeCts?.Cancel(); - _closeCts = null; - } - - private Task CancelCloseTimerAsync() { CancelCloseTimer(); return Task.CompletedTask; } - - private void ScheduleClose() - { - _closeCts?.Cancel(); - _closeCts = new CancellationTokenSource(); - var token = _closeCts.Token; - _ = Task.Run(async () => - { - try - { - await Task.Delay(300, token); - } - catch (OperationCanceledException) { return; } - await InvokeAsync(() => - { - if (token.IsCancellationRequested) return; - _showOptionHelp = false; - _closeCts = null; - StateHasChanged(); - }); - }, token); - } - - private Task ScheduleCloseAsync() { ScheduleClose(); return Task.CompletedTask; } - - private void CloseOptionHelp() - { - CancelCloseTimer(); - _showOptionHelp = false; + _showReadonlyPopover = false; StateHasChanged(); } private async Task HandleReadonlyBadgeClicked(ReadonlyBadgeClickedEventArgs args) { + _readonlyPopoverControllerRef?.CancelCloseFromBadgeEnter(); _readonlyPopoverAnchorId = args.AnchorId; _readonlyPopoverSource = args.Source; _readonlyPopoverOptionName = args.OptionName; _readonlyPopoverValue = args.Value; _showReadonlyPopover = true; - CancelReadonlyCloseTimer(); await InvokeAsync(StateHasChanged); } - private void CancelReadonlyCloseTimer() - { - _readonlyCloseCts?.Cancel(); - _readonlyCloseCts = null; - } - - private Task CancelReadonlyCloseTimerAsync() { CancelReadonlyCloseTimer(); return Task.CompletedTask; } - - private void ScheduleReadonlyClose() - { - _readonlyCloseCts?.Cancel(); - _readonlyCloseCts = new CancellationTokenSource(); - var token = _readonlyCloseCts.Token; - _ = Task.Run(async () => - { - try { await Task.Delay(300, token); } - catch (OperationCanceledException) { return; } - await InvokeAsync(() => - { - if (token.IsCancellationRequested) return; - _showReadonlyPopover = false; - _readonlyCloseCts = null; - StateHasChanged(); - }); - }, token); - } - - private Task ScheduleReadonlyCloseAsync() { ScheduleReadonlyClose(); return Task.CompletedTask; } - - private void CloseReadonlyPopover() - { - CancelReadonlyCloseTimer(); - _showReadonlyPopover = false; - StateHasChanged(); - } - private void OnSectionToggle(string id) { if (_openPanels.Contains(id)) _openPanels.Remove(id); else _openPanels.Add(id); + StateHasChanged(); } private void ExpandAll() @@ -240,24 +134,15 @@ StateHasChanged(); } - private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term) - { - if (string.IsNullOrEmpty(term)) return true; - var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim(); - return searchable.Contains(term, StringComparison.OrdinalIgnoreCase); - } - private void EnterEditMode() { - _isEditMode = true; - _activeFieldKey = null; - _pendingChanges.Clear(); + Session.EnterEditMode(); StateHasChanged(); } private async Task ExitEditModeWithConfirmAsync() { - if (_pendingChanges.Count > 0) + if (Session.PendingChanges.Count > 0) { var module = await JSRuntime.InvokeAsync("import", "./js/dialog.js"); try @@ -270,42 +155,34 @@ await module.DisposeAsync(); } } - _pendingChanges.Clear(); - _activeFieldKey = null; - _isEditMode = false; + Session.ExitEditModeDiscard(); StateHasChanged(); } private void DeactivateActiveFieldIfClicked() { - if (_isEditMode && _activeFieldKey != null) + if (Session.IsEditMode && Session.ActiveFieldKey != null) { - _activeFieldKey = null; + Session.DeactivateField(); StateHasChanged(); } } private void ActivateField(string fieldKey) { - _isEditMode = true; - _activeFieldKey = fieldKey; + Session.ActivateField(fieldKey); StateHasChanged(); } private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args) { - var existing = _pendingChanges.FirstOrDefault(c => c.SectionId == args.SectionId && c.OptionName == args.OptionName); - if (existing != null) - _pendingChanges.Remove(existing); - if (!Equals(args.OldValue, args.NewValue)) - _pendingChanges.Add(new PendingEffectiveConfigChange(args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath)); - _activeFieldKey = null; + Session.TrackCommit(args); StateHasChanged(); } private void OpenSaveModal() { - if (_pendingChanges.Count == 0) return; + if (Session.PendingChanges.Count == 0) return; _showSaveModal = true; StateHasChanged(); } @@ -315,33 +192,4 @@ _showSaveModal = false; StateHasChanged(); } - - private async Task ConfirmSave() - { - if (_pendingChanges.Count == 0) return; - var changes = _pendingChanges.ToList(); - try - { - await ConfigService.ApplyEffectiveConfigChangesAsync(changes); - CloseSaveModal(); - _pendingChanges.Clear(); - _activeFieldKey = null; - _isEditMode = false; - StateHasChanged(); - await OnSaveCompleted.InvokeAsync(); - } - catch (Exception) - { - // TODO: show error to user (e.g. toast or modal message) - await InvokeAsync(StateHasChanged); - } - } - - public ValueTask DisposeAsync() - { - _closeCts?.Cancel(); - _closeCts?.Dispose(); - _closeCts = null; - return ValueTask.CompletedTask; - } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css index 30d6d82..286b38e 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css @@ -13,7 +13,8 @@ overflow: visible; } -.ec-toolbar { +/* Toolbar: single row, search left and actions right. Use ::deep so styles apply to EffectiveConfigToolbar (child) markup. */ +::deep .ec-toolbar { display: flex; flex-wrap: nowrap; align-items: center; @@ -22,14 +23,13 @@ min-width: 0; } -/* Search uses available space; actions stay on the right */ -.ec-toolbar-search { +::deep .ec-toolbar-search { flex: 1 1 auto; min-width: 0; max-width: 24rem; } -.ec-toolbar-actions { +::deep .ec-toolbar-actions { display: flex; flex-wrap: nowrap; flex-shrink: 0; diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSectionPanel.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSectionPanel.razor index 91636c4..cb709e4 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSectionPanel.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSectionPanel.razor @@ -19,10 +19,7 @@ IsActive="@(ActiveFieldKey == fieldKey)" ActiveFieldKey="@ActiveFieldKey" OnActivateEdit="@(() => OnActivateField.InvokeAsync(fieldKey))" - OnEditCommitted="@OnFieldEditCommitted" - OnReadonlyBadgeClicked="@OnReadonlyBadgeClicked" - OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave" - OnOptionHelpRequested="@OnOptionHelpRequested" /> + OnEditCommitted="@OnFieldEditCommitted" /> } @@ -36,16 +33,12 @@ [Parameter] public IReadOnlyList Descriptors { get; set; } = null!; [Parameter] public bool IsOpen { get; set; } [Parameter] public EventCallback OnToggle { get; set; } - /// Raised when user requests option help (click or mouse leave from label). Passed through from Section. - [Parameter] public EventCallback OnOptionHelpRequested { get; set; } /// Cascaded from EffectiveConfigSection. View = read-only; Edit = editable. [Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View; [Parameter] public bool IsEditMode { get; set; } [Parameter] public string? ActiveFieldKey { get; set; } [Parameter] public EventCallback OnActivateField { get; set; } [Parameter] public EventCallback OnFieldEditCommitted { get; set; } - [Parameter] public EventCallback OnReadonlyBadgeClicked { get; set; } - [Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; } private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId); } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigToolbar.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigToolbar.razor new file mode 100644 index 0000000..990b4cd --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigToolbar.razor @@ -0,0 +1,54 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig + +
    + @if (ShowSearchBox) + { + + } +
    + + + @if (IsEditMode) + { + + @if (PendingChangesCount > 0) + { + @PendingChangesCount change(s) + } + + } + else + { + + } +
    +
    + +@code { + [Parameter] public bool ShowSearchBox { get; set; } = true; + [Parameter] public string SearchTerm { get; set; } = ""; + [Parameter] public EventCallback SearchTermChanged { get; set; } + [Parameter] public bool IsEditMode { get; set; } + [Parameter] public int PendingChangesCount { get; set; } + [Parameter] public EventCallback OnExpandAll { get; set; } + [Parameter] public EventCallback OnCollapseAll { get; set; } + [Parameter] public EventCallback OnEnterEditMode { get; set; } + [Parameter] public EventCallback OnExitEditModeWithConfirm { get; set; } + [Parameter] public EventCallback OnOpenSaveModal { get; set; } + + private async Task OnSearchInput(ChangeEventArgs e) + { + await SearchTermChanged.InvokeAsync(e.Value?.ToString() ?? ""); + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigValueDisplayBase.cs b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigValueDisplayBase.cs new file mode 100644 index 0000000..d785f2b --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigValueDisplayBase.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Infrastructure.Services.Abstractions; + +namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig; + +/// +/// Base for effective-config custom value displays. Provides shared parameters, +/// , and so pending changes are shown before save. +/// +public abstract class EffectiveConfigValueDisplayBase : ComponentBase +{ + [Parameter] + public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!; + + [CascadingParameter] + public bool IsActiveEditor { get; set; } + + [CascadingParameter] + public EventCallback OnCommitRequested { get; set; } + + [Parameter] + public EventCallback ValueChanged { get; set; } + + [CascadingParameter] + public IEffectiveConfigEditSession? Session { get; set; } + + protected Task NotifyValueChanged(object? value) => ValueChanged.InvokeAsync(value); + + /// Returns the value to display: pending change NewValue if this field has one, else descriptor value (not yet written to disk). + protected object? GetEffectiveDisplayValue() + { + var pending = Session?.PendingChanges.FirstOrDefault(c => + string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) && + string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase)); + return pending != null ? pending.NewValue : Descriptor.GetValue(); + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionHelpController.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionHelpController.razor new file mode 100644 index 0000000..5cd759d --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionHelpController.razor @@ -0,0 +1,51 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig + + + +@code { + [Parameter] public bool Visible { get; set; } + [Parameter] public string? HelpKey { get; set; } + [Parameter] public string? AnchorId { get; set; } + [Parameter] public string Title { get; set; } = "Option help"; + [Parameter] public EventCallback OnCloseRequested { get; set; } + + private CancellationTokenSource? _closeCts; + + private void CancelCloseTimer() + { + _closeCts?.Cancel(); + _closeCts = null; + } + + private void ScheduleClose() + { + _closeCts?.Cancel(); + _closeCts = new CancellationTokenSource(); + var token = _closeCts.Token; + _ = Task.Run(async () => + { + try { await Task.Delay(300, token); } + catch (OperationCanceledException) { return; } + await InvokeAsync(async () => + { + if (token.IsCancellationRequested) return; + _closeCts = null; + await OnCloseRequested.InvokeAsync(); + }); + }, token); + } + + private async Task RequestClose() + { + CancelCloseTimer(); + await OnCloseRequested.InvokeAsync(); + } + + /// Called by the section when the label loses mouse (so we schedule close; if user moves to modal, OnMouseEnterModal cancels). + public void ScheduleCloseFromLabelLeave() + { + ScheduleClose(); + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionHelpLabel.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionHelpLabel.razor new file mode 100644 index 0000000..c729bb0 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionHelpLabel.razor @@ -0,0 +1,37 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig + + + @ChildContent + + +@code { + [Parameter] public string AnchorId { get; set; } = ""; + [Parameter] public string? HelpKey { get; set; } + [Parameter] public string OptionLabel { get; set; } = ""; + [Parameter] public string? CssClass { get; set; } + [Parameter] public string? Title { get; set; } + [CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + private Task OnClick() + { + if (Ui == null || string.IsNullOrEmpty(HelpKey)) return Task.CompletedTask; + return Ui.RequestOptionHelpAsync(new ConfigOptionHelpRequestEventArgs + { + HelpKey = HelpKey, + OptionLabel = OptionLabel, + AnchorId = AnchorId + }); + } + + private Task OnMouseLeave() + { + if (Ui == null) return Task.CompletedTask; + return Ui.RequestOptionHelpAsync(new ConfigOptionHelpRequestEventArgs + { + AnchorId = AnchorId, + IsLabelMouseLeave = true + }); + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyBadge.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyBadge.razor new file mode 100644 index 0000000..b0503ef --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyBadge.razor @@ -0,0 +1,40 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.Dnsmasq +@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig + +@if (Source?.IsReadOnly == true) +{ + @if (IsInteractive && Ui != null) + { + + } + else + { + readonly + } +} + +@code { + [Parameter] public string AnchorId { get; set; } = ""; + [Parameter] public ConfigValueSource? Source { get; set; } + [Parameter] public string OptionName { get; set; } = ""; + [Parameter] public object? Value { get; set; } + /// When true, render as button and raise click/mouseleave via context. + [Parameter] public bool IsInteractive { get; set; } + [CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; } + + private Task OnClick() + { + if (Ui == null || Source == null) return Task.CompletedTask; + return Ui.RequestReadonlyPopoverAsync(new ReadonlyBadgeClickedEventArgs(AnchorId, Source, OptionName, Value)); + } + + private Task OnMouseLeave() + { + if (Ui == null) return Task.CompletedTask; + return Ui.ScheduleReadonlyPopoverCloseAsync(); + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyEditHint.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyEditHint.razor index 9ceaf6b..a2fa94f 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyEditHint.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyEditHint.razor @@ -1,7 +1,6 @@ @namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Infrastructure.Helpers.Config -@inject IJSRuntime JSRuntime @if (Source is { IsReadOnly: true }) { @@ -9,17 +8,15 @@ @ReadonlyConfigCommandGenerator.GetEditFileHint(Source) @if (!string.IsNullOrEmpty(_removeCommand)) { -
    - @_removeCommand - +
    +
    } @if (!string.IsNullOrEmpty(_overrideLine)) { @ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath) -
    - @_overrideLine - +
    +
    }
    @@ -47,30 +44,4 @@ _overrideLine = ""; } } - - private async Task CopyRemoveCommand() - { - if (string.IsNullOrEmpty(_removeCommand)) return; - try - { - await JSRuntime.InvokeVoidAsync("copyToClipboard", _removeCommand); - } - catch - { - // Copy not supported or failed - } - } - - private async Task CopyOverrideLine() - { - if (string.IsNullOrEmpty(_overrideLine)) return; - try - { - await JSRuntime.InvokeVoidAsync("copyToClipboard", _overrideLine); - } - catch - { - // Copy not supported or failed - } - } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyEditPopover.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyEditPopover.razor index 724b92f..47eb882 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyEditPopover.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyEditPopover.razor @@ -19,18 +19,14 @@

    @ReadonlyConfigCommandGenerator.GetEditFileHint(Source)

    @if (!string.IsNullOrEmpty(_removeCommand)) { -
    - @_removeCommand - +
    +
    } @if (!string.IsNullOrEmpty(_overrideLine)) {

    @ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)

    -
    - @_overrideLine - -
    + }
    @@ -48,6 +44,9 @@ [Parameter] public EventCallback OnMouseEnterPopover { get; set; } [Parameter] public EventCallback OnMouseLeavePopover { get; set; } + private const string HiddenPositionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;"; + private const string VisiblePositionStyleFormat = "position: fixed; top: {0}px; left: {1}px; visibility: visible;"; + private string _positionStyle = ""; private readonly string _popoverId = "ec-readonly-popover-" + Guid.NewGuid().ToString("N")[..8]; private DotNetObjectReference? _dotNetRef; @@ -71,7 +70,7 @@ _overrideLine = ""; } if (Visible && string.IsNullOrEmpty(_positionStyle)) - _positionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;"; + _positionStyle = HiddenPositionStyle; } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -143,11 +142,11 @@ var gap = 4; var topPx = r.Bottom + gap; var leftPx = r.Left; - _positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;"; + _positionStyle = string.Format(VisiblePositionStyleFormat, topPx, leftPx); } else { - _positionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;"; + _positionStyle = HiddenPositionStyle; } } @@ -163,18 +162,6 @@ await OnMouseLeavePopover.InvokeAsync(); } - private async Task CopyRemoveCommand() - { - if (string.IsNullOrEmpty(_removeCommand)) return; - try { await JSRuntime.InvokeVoidAsync("copyToClipboard", _removeCommand); } catch { } - } - - private async Task CopyOverrideLine() - { - if (string.IsNullOrEmpty(_overrideLine)) return; - try { await JSRuntime.InvokeVoidAsync("copyToClipboard", _overrideLine); } catch { } - } - private sealed record BoundingRect( [property: JsonPropertyName("top")] double Top, [property: JsonPropertyName("left")] double Left, diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyPopoverController.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyPopoverController.razor new file mode 100644 index 0000000..f5a866a --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ReadonlyPopoverController.razor @@ -0,0 +1,64 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.Dnsmasq +@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig + + + +@code { + [Parameter] public bool Visible { get; set; } + [Parameter] public string? AnchorId { get; set; } + [Parameter] public ConfigValueSource? Source { get; set; } + [Parameter] public string OptionName { get; set; } = ""; + [Parameter] public object? Value { get; set; } + [Parameter] public string? ManagedFilePath { get; set; } + [Parameter] public EventCallback OnCloseRequested { get; set; } + + private CancellationTokenSource? _closeCts; + + private void CancelCloseTimer() + { + _closeCts?.Cancel(); + _closeCts = null; + } + + private void ScheduleClose() + { + _closeCts?.Cancel(); + _closeCts = new CancellationTokenSource(); + var token = _closeCts.Token; + _ = Task.Run(async () => + { + try { await Task.Delay(300, token); } + catch (OperationCanceledException) { return; } + await InvokeAsync(async () => + { + if (token.IsCancellationRequested) return; + _closeCts = null; + await OnCloseRequested.InvokeAsync(); + }); + }, token); + } + + private async Task RequestClose() + { + CancelCloseTimer(); + await OnCloseRequested.InvokeAsync(); + } + + /// + /// Called when mouse leaves readonly badge; close unless pointer enters popover quickly. + /// + public void ScheduleCloseFromBadgeLeave() + { + ScheduleClose(); + } + + /// + /// Called when readonly badge is interacted with to keep popover open. + /// + public void CancelCloseFromBadgeEnter() + { + CancelCloseTimer(); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Abstractions/IEffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/Abstractions/IEffectiveConfigEditSession.cs new file mode 100644 index 0000000..471eeb7 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Abstractions/IEffectiveConfigEditSession.cs @@ -0,0 +1,24 @@ +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.Abstractions; + +/// +/// Scoped orchestrator for effective-config edit lifecycle: edit mode, active field, pending changes, and apply. +/// Components read state from here and call methods; the owning section re-renders after state changes. +/// +public interface IEffectiveConfigEditSession : IApplicationScopedService +{ + bool IsEditMode { get; } + string? ActiveFieldKey { get; } + IReadOnlyList PendingChanges { get; } + + void EnterEditMode(); + void ExitEditModeDiscard(); + void ActivateField(string fieldKey); + void DeactivateField(); + + void TrackCommit(EffectiveConfigEditCommittedArgs args); + void RevertChange(string sectionId, string optionName); + + Task ApplyAsync(CancellationToken ct = default); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfigEditSession.cs new file mode 100644 index 0000000..4ff943b --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfigEditSession.cs @@ -0,0 +1,72 @@ +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Infrastructure.Services.Abstractions; + +namespace DnsmasqWebUI.Infrastructure.Services; + +public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession +{ + private readonly IDnsmasqConfigService _configService; + private readonly List _pending = new(); + + public EffectiveConfigEditSession(IDnsmasqConfigService configService) + { + _configService = configService; + } + + public bool IsEditMode { get; private set; } + public string? ActiveFieldKey { get; private set; } + public IReadOnlyList PendingChanges => _pending; + + public void EnterEditMode() + { + IsEditMode = true; + ActiveFieldKey = null; + _pending.Clear(); + } + + public void ExitEditModeDiscard() + { + _pending.Clear(); + ActiveFieldKey = null; + IsEditMode = false; + } + + public void ActivateField(string fieldKey) + { + IsEditMode = true; + ActiveFieldKey = fieldKey; + } + + public void DeactivateField() + { + ActiveFieldKey = null; + } + + public void TrackCommit(EffectiveConfigEditCommittedArgs args) + { + var existing = _pending.FirstOrDefault(c => + c.SectionId == args.SectionId && c.OptionName == args.OptionName); + if (existing != null) + _pending.Remove(existing); + if (!Equals(args.OldValue, args.NewValue)) + _pending.Add(new PendingEffectiveConfigChange( + args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath)); + ActiveFieldKey = null; + } + + public void RevertChange(string sectionId, string optionName) + { + var existing = _pending.FirstOrDefault(c => + c.SectionId == sectionId && c.OptionName == optionName); + if (existing != null) + _pending.Remove(existing); + } + + public async Task ApplyAsync(CancellationToken ct = default) + { + if (_pending.Count == 0) return; + var changes = _pending.ToList(); + await _configService.ApplyEffectiveConfigChangesAsync(changes, ct); + ExitEditModeDiscard(); + } +} diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigUiContext.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigUiContext.cs new file mode 100644 index 0000000..91b8316 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigUiContext.cs @@ -0,0 +1,14 @@ +namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +/// +/// Cascaded context for effective-config UI intents. Children call these methods instead of +/// receiving 6–8 event callbacks; the section (or shell) provides the implementation. +/// +public sealed class EffectiveConfigUiContext +{ + public required Func RequestOptionHelpAsync { get; init; } + public required Func RequestReadonlyPopoverAsync { get; init; } + public required Func ScheduleReadonlyPopoverCloseAsync { get; init; } + public required Func ActivateFieldAsync { get; init; } + public required Func CommitFieldAsync { get; init; } +}