diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor index a6e9fbc..f074ea4 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor @@ -44,13 +44,10 @@ } else if (Descriptor.IsMultiValue) { - var items = _hasPendingChange && _effectiveValue is IReadOnlyList pendingList - ? pendingList.Select(s => new ValueWithSource(s, null)).ToList() - : Descriptor.GetItems(); - var allReadonly = items != null && items.Count > 0 && items.All(i => i.Source?.IsReadOnly == true); - } else @@ -98,6 +95,15 @@ else private bool _wasActive; private ExitAction _exitAction = ExitAction.None; + /// Cached for multi-value: items to pass to the row (with sources preserved for draft merge). + private IReadOnlyList? _multiValueItems; + /// Cached for multi-value: true when there is an uncommitted draft list. + private bool _multiValueHasDraft; + /// Cached for multi-value: row-level source for badge (readonly when all items readonly and not edit mode). + private ConfigValueSource? _multiValueRowSource; + /// Cached for multi-value: whether row should show ok/cancel (session pending or local draft). + private bool _multiValueHasPendingOrDraft; + protected override void OnParametersSet() { _customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName); @@ -112,6 +118,8 @@ else : Descriptor.GetValue(); _hasPendingChange = pending != null; _effectiveValue = pending != null ? pending.NewValue : descriptorValue; + if (Descriptor.IsMultiValue) + ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList, _draftValue as IReadOnlyList); if (!_wasActive && IsActive) _valueAtActivation = descriptorValue; if (_wasActive && !IsActive && _exitAction == ExitAction.None) @@ -121,6 +129,45 @@ else _wasActive = IsActive; } + private void ComputeMultiValueState(IReadOnlyList? descriptorItems, IReadOnlyList? pendingList, IReadOnlyList? draftList) + { + var descriptorValues = descriptorItems?.Select(i => i.Value).ToList() ?? new List(); + _multiValueHasDraft = draftList != null && !ValuesEqual(descriptorValues, draftList); + if (_multiValueHasDraft && draftList != null) + { + _multiValueItems = BuildItemsWithSource(descriptorItems, draftList); + } + else if (_hasPendingChange && pendingList != null) + { + _multiValueItems = BuildItemsWithSource(descriptorItems, pendingList); + } + else + { + _multiValueItems = descriptorItems; + } + var allReadonly = _multiValueItems != null && _multiValueItems.Count > 0 && _multiValueItems.All(i => i.Source?.IsReadOnly == true); + _multiValueRowSource = allReadonly && !IsEditMode ? _multiValueItems?.FirstOrDefault()?.Source : null; + _multiValueHasPendingOrDraft = _hasPendingChange || _multiValueHasDraft; + } + + private static IReadOnlyList BuildItemsWithSource(IReadOnlyList? descriptorItems, IReadOnlyList values) + { + var pool = descriptorItems?.ToList() ?? new List(); + var list = new List(values.Count); + foreach (var s in values) + { + var idx = pool.FindIndex(x => string.Equals(x.Value, s, StringComparison.Ordinal)); + if (idx >= 0) + { + list.Add(pool[idx]); + pool.RemoveAt(idx); + } + else + list.Add(new ValueWithSource(s, null)); + } + return list; + } + private void HandleValueChanged(object? value) { _draftValue = value; @@ -145,7 +192,13 @@ else } var source = Descriptor.GetSource(); - var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath); + object? valueToCommit = newValue; + if (newValue is IReadOnlyList list) + { + var deduped = list.Distinct(StringComparer.Ordinal).ToList(); + valueToCommit = deduped; + } + var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, valueToCommit, source?.FilePath); if (Ui != null) await Ui.CommitFieldAsync(args); } @@ -171,6 +224,9 @@ else private void HandleMultiValueChanged(IReadOnlyList values) { _draftValue = values.ToList(); + if (Descriptor.IsMultiValue) + ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList, _draftValue as IReadOnlyList); + StateHasChanged(); } private async Task HandleConfirmEdit() diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor index 95e04a5..5574ff6 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor @@ -2,9 +2,12 @@ @using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig - + @Value - + @code { @@ -13,4 +16,7 @@ [Parameter] public string OptionName { get; set; } = ""; [Parameter] public bool IsEditMode { get; set; } [Parameter] public string? ReadonlyBadgeAnchorId { get; set; } + [Parameter] public string? FieldKey { get; set; } + [Parameter] public bool ReadonlyClickActivatesField { get; set; } + [Parameter] public string? ReadonlyTitleOverride { get; set; } } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor new file mode 100644 index 0000000..2e147f5 --- /dev/null +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor @@ -0,0 +1,167 @@ +@namespace DnsmasqWebUI.Components.EffectiveConfig +@using DnsmasqWebUI.Models.Dnsmasq +@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig + + +
    + @if (SourceItems?.Count > 0) + { + @for (var i = 0; i < SourceItems.Count; i++) + { + var item = SourceItems[i]; + var anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}"; + var itemReadonly = item.Source?.IsReadOnly == true; + var rowIndex = i; + var rowValue = item.Value; + +
  1. + @if (_editingIndex == rowIndex && !itemReadonly) + { + + + + + + } + else + { + @rowValue + + + + @if (!itemReadonly) + { + + } + + } +
  2. + } + } + else + { +
  3. + (none) +
  4. + } + + @if (AllowAddManagedValue) + { +
  5. + @if (_addingNew) + { + + + + + + } + else + { + + } +
  6. + } +
+
+ +@code { + [Parameter] public IReadOnlyList? SourceItems { get; set; } + [Parameter] public string OptionName { get; set; } = ""; + [Parameter] public string ReadonlyBadgeAnchorIdBase { get; set; } = ""; + + [Parameter] public bool IsEditMode { get; set; } + [Parameter] public bool AllowAddManagedValue { get; set; } = true; + + [Parameter] public EventCallback> OnValuesChanged { get; set; } + + private const string AddItemKey = "add-item"; + private int? _editingIndex; + private bool _addingNew; + private string _draftValue = ""; + + private void BeginEdit(int index, string current) + { + _addingNew = false; + _editingIndex = index; + _draftValue = current; + } + + private void BeginAdd() + { + _editingIndex = null; + _addingNew = true; + _draftValue = ""; + } + + private void CancelInline() + { + _editingIndex = null; + _addingNew = false; + _draftValue = ""; + } + + private async Task SaveEdit(int index) + { + var next = (SourceItems ?? Array.Empty()) + .Select(x => x.Value) + .ToList(); + if (index < 0 || index >= next.Count) return; + + next[index] = (_draftValue ?? "").Trim(); + await OnValuesChanged.InvokeAsync(next); + CancelInline(); + } + + private async Task SaveNew() + { + var value = (_draftValue ?? "").Trim(); + if (value.Length == 0) return; + + var next = (SourceItems ?? Array.Empty()) + .Select(x => x.Value) + .ToList(); + + next.Add(value); + await OnValuesChanged.InvokeAsync(next); + CancelInline(); + } + + private async Task RemoveAt(int index) + { + if (SourceItems == null || index < 0 || index >= SourceItems.Count) return; + var item = SourceItems[index]; + if (item?.Source?.IsReadOnly == true) return; + + var next = (SourceItems ?? Array.Empty()) + .Select(x => x.Value) + .ToList(); + if (index < 0 || index >= next.Count) return; + + next.RemoveAt(index); + await OnValuesChanged.InvokeAsync(next); + } +} diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor.css b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor.css new file mode 100644 index 0000000..16c6a92 --- /dev/null +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor.css @@ -0,0 +1,65 @@ +/* Editor block: distinct from display mode (border + background) */ +.ec-multi-list-editor { + display: block; + padding: 0.75rem 1rem; + margin-top: 0.25rem; + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.375rem; + background-color: var(--bs-body-bg, #fff); +} + +.ec-multi-list-editor .ec-field-value-list { + margin-bottom: 0; +} + +.ec-multi-list-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + min-height: 2rem; +} + +.ec-multi-list-row .ec-field-value-item { + min-width: 0; +} + +.ec-multi-list-row .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 { + max-width: 28rem; + flex-shrink: 0; +} + +.ec-multi-list-empty-hint { + list-style: none; + margin-left: -1.25rem; +} + +.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); + flex-wrap: nowrap; +} + +.ec-multi-list-add-item .form-control.ec-multi-list-input { + flex: 1 1 auto; + max-width: none; + min-width: 12rem; +} + +.ec-multi-list-add-item .status-badge-inline { + margin-left: auto; +} + +.ec-multi-list-add-btn { + font-size: 0.875rem; +} diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor index 095d387..3044d35 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor @@ -6,13 +6,17 @@ @Label: @if (IsActiveEditor) { - - - - + + } else { @@ -23,7 +27,7 @@ @foreach (var (item, index) in Items!.Select((item, index) => (item, index))) { var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : ""; -
  • +
  • } } @@ -31,10 +35,6 @@ { (none) } -
    } @@ -56,22 +56,4 @@ [Parameter] public EventCallback OnConfirmRequested { get; set; } [Parameter] public EventCallback OnCancelRequested { get; set; } [Parameter] public EventCallback OnRevertRequested { get; set; } - - private string _editLines = ""; - - protected override void OnParametersSet() - { - if (Items != null && Items.Count > 0) - _editLines = string.Join("\n", Items.Select(i => i.Value)); - else - _editLines = ""; - } - - private async Task OnLinesInput(ChangeEventArgs e) - { - var text = e.Value?.ToString() ?? ""; - _editLines = text; - var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.Trim()).Where(l => l.Length > 0).ToList(); - await ItemsChanged.InvokeAsync(lines); - } } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor index 88c870e..c425dae 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor @@ -28,6 +28,7 @@ Review, Saving, WriteFailed, + ValidateFailed, ReloadFailed, RestoreInProgress, RestoreFailed @@ -83,7 +84,9 @@ return; } - if (result.Saved && !result.Restarted) + if (result.Saved && result.IsValidateFailed) + _saveState = SaveUiState.ValidateFailed; + else if (result.Saved && !result.Restarted) _saveState = SaveUiState.ReloadFailed; else if (!result.Saved) _saveState = SaveUiState.WriteFailed; diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor index 5f52bde..3f8e1ae 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor @@ -15,7 +15,28 @@ {
    -

    @(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving and restarting…" : "Restoring backup and restarting…")

    +

    @(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving, validating and restarting…" : "Restoring backup and restarting…")

    +
    + } + else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed && SaveResult != null) + { +
    +
    + +
    + Config was saved, but validation failed. Restart was not attempted. You can restore the backup or keep editing. +
    +
    + @if (SaveResult.BackupPath != null) + { +

    + Backup: @SaveResult.BackupPath +

    + } +
    +

    Validation output

    +
    @GetValidationFailedOutput(SaveResult)
    +
    } else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null) @@ -129,7 +150,7 @@ }