| File: Model\ParameterInteractionState.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 Aspire.Dashboard.Model.Interaction; namespace Aspire.Dashboard.Model; /// <summary> /// Coordinates the AppHost's unresolved parameters interactions with the inline parameter forms /// (<c>MissingParametersPanel</c>) of the current circuit. /// </summary> /// <remarks> /// The AppHost first shows a notification, and prompts for the values with an inputs interaction once the /// notification is accepted. A form accepts the notification when it can show every parameter the notification is /// about, and the inputs interaction is shown in a form only while a mounted form shows all of its inputs. Otherwise /// the interaction is shown as the usual dialog, so the AppHost is never left waiting on a prompt nobody can see. /// All members are called from the circuit's renderer dispatcher. /// </remarks> public sealed class ParameterInteractionState { // The inputs interaction adds this input to save the values. It isn't a parameter. internal const string SaveToUserSecretsInputName = "SaveToUserSecrets"; private readonly List<PanelRegistration> _panels = []; private PendingNotification? _notification; private DialogInteraction? _dialogInteraction; /// <summary> /// Gets the inputs interaction shown inline, if any. /// </summary> public InteractionsInputsDialogViewModel? Current { get; private set; } /// <summary> /// Raised when <see cref="Current"/> or its inputs change. /// </summary> public event Action? Changed; /// <summary> /// Raised when no mounted form can show <see cref="Current"/> anymore, for example after navigating away from it. /// <see cref="Current"/> is cleared first, and the handler shows the interaction another way. /// </summary> public event Action<InteractionsInputsDialogViewModel>? Released; /// <summary> /// Registers a mounted form. Dispose the registration when the form is removed. /// </summary> public PanelRegistration RegisterPanel() { var panel = new PanelRegistration(this); _panels.Add(panel); return panel; } /// <summary> /// Records the unresolved parameters notification, and how to accept it. /// </summary> public void SetNotification(int interactionId, IReadOnlyCollection<string> parameterNames, Func<Task> accept) { _notification = new PendingNotification(interactionId, parameterNames, accept); Changed?.Invoke(); } /// <summary> /// Accepts the unresolved parameters notification, so the AppHost prompts for the values, when the given form can /// show all of the notification's parameters. A form that shows only some of them doesn't, because the prompt /// would ask for parameters the form can't show. /// </summary> public async Task BeginInteractionAsync(PanelRegistration panel) { if (Current is not null || _notification is not { AcceptRequested: false } notification || !panel.Shows(notification.ParameterNames)) { return; } notification.AcceptRequested = true; await notification.Accept().ConfigureAwait(false); } /// <summary> /// Shows the inputs interaction inline when a mounted form shows all of its inputs. /// </summary> /// <returns><c>true</c> when the interaction is shown inline, otherwise <c>false</c>.</returns> public bool TrySetCurrent(InteractionsInputsDialogViewModel current) { // Only one prompt is shown inline. Another one, which the AppHost doesn't send for parameters, gets a dialog. if (Current is not null && Current.Interaction.InteractionId != current.Interaction.InteractionId) { return false; } var inputNames = GetParameterInputNames(current); if (inputNames.Count == 0 || !_panels.Any(panel => panel.Shows(inputNames))) { return false; } Current = current; current.OnInteractionUpdated = () => { Changed?.Invoke(); return Task.CompletedTask; }; Changed?.Invoke(); return true; } /// <summary> /// Records an inputs interaction that's shown in a dialog because no mounted form could show it. A form that can /// show it takes it from the dialog when it's mounted: after reloading the page on the form, the AppHost's prompt /// is replayed before the form is rendered, and the form is where the user went to enter the values. /// </summary> /// <param name="interaction">The interaction shown in the dialog.</param> /// <param name="closeDialog">Closes the dialog without answering the interaction.</param> public void SetDialogInteraction(InteractionsInputsDialogViewModel interaction, Func<Task> closeDialog) { _dialogInteraction = new DialogInteraction(interaction, closeDialog); TryTakeDialogInteraction(); } /// <summary> /// Forgets the interaction shown in a dialog after the dialog closed. /// </summary> public void ClearDialogInteraction(int interactionId) { if (_dialogInteraction?.Interaction.Interaction.InteractionId == interactionId) { _dialogInteraction = null; } } /// <summary> /// Forgets the given interaction, which the AppHost completed. /// </summary> public void Clear(int interactionId) { ClearDialogInteraction(interactionId); var changed = false; if (_notification?.InteractionId == interactionId) { // Accepting or dismissing the notification completes it. A form mounted later must not accept it again. _notification = null; changed = true; } if (Current?.Interaction.InteractionId == interactionId) { Current.OnInteractionUpdated = null; Current = null; changed = true; } if (changed) { Changed?.Invoke(); } } private static List<string> GetParameterInputNames(InteractionsInputsDialogViewModel interaction) => interaction.Inputs .Select(input => input.Name) .Where(name => !string.Equals(name, SaveToUserSecretsInputName, StringComparisons.InteractionInputName)) .ToList(); private void OnPanelsChanged() { if (Current is not { } current) { TryTakeDialogInteraction(); return; } if (_panels.Any(panel => panel.Shows(GetParameterInputNames(current)))) { return; } current.OnInteractionUpdated = null; Current = null; Changed?.Invoke(); Released?.Invoke(current); } private void TryTakeDialogInteraction() { if (Current is not null || _dialogInteraction is not { } dialogInteraction || !TrySetCurrent(dialogInteraction.Interaction)) { return; } _dialogInteraction = null; _ = dialogInteraction.CloseDialog(); } /// <summary> /// A mounted form, and the parameters it shows. /// </summary> public sealed class PanelRegistration : IDisposable { private readonly ParameterInteractionState _owner; private HashSet<string> _parameterNames = new(StringComparers.ResourceName); private bool _disposed; internal PanelRegistration(ParameterInteractionState owner) { _owner = owner; } /// <summary> /// Sets the names of the parameters the form has an editor for. /// </summary> public void SetParameterNames(IEnumerable<string> parameterNames) { var names = parameterNames.ToHashSet(StringComparers.ResourceName); if (_disposed || names.SetEquals(_parameterNames)) { return; } _parameterNames = names; _owner.OnPanelsChanged(); } internal bool Shows(IEnumerable<string> parameterNames) => parameterNames.All(_parameterNames.Contains); /// <summary> /// Gets a value indicating whether the form shows every input of the given interaction. /// </summary> public bool Shows(InteractionsInputsDialogViewModel interaction) => Shows(GetParameterInputNames(interaction)); public void Dispose() { if (_disposed) { return; } _disposed = true; _owner._panels.Remove(this); _owner.OnPanelsChanged(); } } private sealed record DialogInteraction(InteractionsInputsDialogViewModel Interaction, Func<Task> CloseDialog); private sealed class PendingNotification(int interactionId, IReadOnlyCollection<string> parameterNames, Func<Task> accept) { public int InteractionId { get; } = interactionId; public IReadOnlyCollection<string> ParameterNames { get; } = parameterNames; public Func<Task> Accept { get; } = accept; public bool AcceptRequested { get; set; } } }