| File: Model\Interaction\InputViewModel.cs | Web Access |
| Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using Aspire.DashboardService.Proto.V1; namespace Aspire.Dashboard.Model.Interaction; public sealed class InputViewModel { // Fallback maximum upload size matching the server's default (100 MB). // In practice the server always sends a MaxFileSize value for file inputs, // so this constant is only used as a defensive safety net. internal const long DefaultMaxUploadedFileBytes = 100 * 1024 * 1024; // 100 MB private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; public InteractionInput Input { get; private set; } = default!; public InputViewModel(InteractionInput input) { SetInput(input); } public void SetInput(InteractionInput input) { // Interaction updates carry a full server-side snapshot even when only one input changed. Keep // local values by default so an update for a dependent choice does not clobber text the user is // typing elsewhere in the dialog. ShouldUseIncomingValue captures the cases where the server is // authoritative because the field is being dynamically loaded or is not currently editable. if (Input is not null && !ShouldUseIncomingValue(Input, input)) { input.Value = Input.Value; } Input = input; if (input.InputType == InputType.Choice && input.Options != null) { var optionsVM = input.Options .Select(option => new SelectViewModel<string> { Id = option.Key, Name = option.Value, }) .ToList(); // Only update the options if they have changed to avoid unnecessarily recreating the FluentSelect component. if (!OptionsEqual(SelectOptions, optionsVM)) { SelectOptions = optionsVM; ChoiceVersion++; } // Default to the first option if no placeholder is set, the value is empty, and custom choice is disabled. // This is done so the input model value matches frontend behavior (FluentSelect defaults to the first option) if (string.IsNullOrEmpty(input.Placeholder) && string.IsNullOrEmpty(input.Value) && optionsVM.Count > 0 && !input.AllowCustomChoice) { input.Value = optionsVM[0].Id; } } } public List<SelectViewModel<string>> SelectOptions { get; private set; } = []; /// <summary> /// Incremented each time <see cref="SelectOptions"/> is rebuilt so Blazor /// recreates the choice input component when options change, avoiding a race /// where the web component clears the bound value during an options refresh. /// </summary> public int ChoiceVersion { get; private set; } /// <summary> /// A key unique per input that changes when <see cref="ChoiceVersion"/> changes. /// Used as a Blazor <c>@key</c> on FluentSelect / FluentCombobox. /// </summary> public string InputKey => $"{Input.Name}_{ChoiceVersion}"; public IEnumerable<SelectViewModel<string>> FilteredOptions() { if (Value is not { Length: > 0 } value) { return SelectOptions; } var filteredValues = SelectOptions.Where(vm => vm.Name.Contains(value, StringComparison.OrdinalIgnoreCase)); // If no values match the filter, don't apply the filter. // This improves user experience and fixes some combobox issues. // https://github.com/microsoft/fluentui-blazor/issues/4314#issuecomment-3577475233 if (!filteredValues.Any()) { filteredValues = SelectOptions; } return filteredValues; } public string? Value { get => Input.Value; set => Input.Value = value; } // Used when binding to FluentCheckbox. public bool IsChecked { get => bool.TryParse(Input.Value, out var result) && result; set => Input.Value = value ? "true" : "false"; } // Used when binding to FluentNumberField. public int? NumberValue { get => int.TryParse(Input.Value, CultureInfo.InvariantCulture, out var result) ? result : null; set => Input.Value = value?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; } public bool InputDisabled => Input.Disabled || Input.Loading; // Used to track secret text visibility state public bool IsSecretTextVisible { get; set; } // Tracks the uploaded file references for File inputs. // When set, serializes successful references (Id != null) to JSON on the underlying Input.Value. public List<FileReferenceViewModel> FileReferences { get; } = []; public void SetFileReferences(IEnumerable<FileReferenceViewModel> files) { FileReferences.Clear(); FileReferences.AddRange(files); var successfulRefs = FileReferences.Where(f => f.Id is not null).ToList(); // Use empty string (not "[]") when no files were accepted, so required-field checks work correctly. Input.Value = successfulRefs.Count > 0 ? JsonSerializer.Serialize(successfulRefs, s_jsonSerializerOptions) : string.Empty; } private static bool OptionsEqual(List<SelectViewModel<string>> existing, List<SelectViewModel<string>> incoming) { if (existing.Count != incoming.Count) { return false; } for (var i = 0; i < existing.Count; i++) { if (existing[i].Id != incoming[i].Id || existing[i].Name != incoming[i].Name) { return false; } } return true; } private static bool ShouldUseIncomingValue(InteractionInput current, InteractionInput incoming) { // Dynamic loading can replace both the option list and the selected value. When loading // completes, the server value is the one validated against the freshly loaded options. // // Disabled inputs are also server-owned because the user could not have made a meaningful local // edit while the control was unavailable. This includes disabled -> enabled transitions, such as // Azure Subscription ID becoming editable after tenant-specific subscriptions are loaded. return (current.Loading && !incoming.Loading) || current.Disabled || incoming.Disabled; } } public sealed class FileReferenceViewModel { public string? Id { get; set; } public required string Name { get; set; } [JsonIgnore] public string? ErrorMessage { get; set; } }