Skip to content

Add Name property to InteractionInput and enable name-based access in results #10835

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 179 additions & 3 deletions src/Aspire.Hosting/IInteractionService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;

namespace Aspire.Hosting;
Expand Down Expand Up @@ -80,9 +82,9 @@ public interface IInteractionService
/// <param name="options">Optional configuration for the input dialog interaction.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>
/// An <see cref="InteractionResult{T}"/> containing the user's inputs.
/// An <see cref="InteractionResult{T}"/> containing the user's inputs as an <see cref="InteractionInputCollection"/>.
/// </returns>
Task<InteractionResult<IReadOnlyList<InteractionInput>>> PromptInputsAsync(string title, string? message, IReadOnlyList<InteractionInput> inputs, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default);
Task<InteractionResult<InteractionInputCollection>> PromptInputsAsync(string title, string? message, IReadOnlyList<InteractionInput> inputs, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default);

/// <summary>
/// Prompts the user with a notification.
Expand All @@ -103,6 +105,12 @@ public interface IInteractionService
[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public sealed class InteractionInput
{
/// <summary>
/// Gets or sets the name for the input. Used for accessing inputs by name from a keyed collection.
/// If not specified, a name will be generated automatically.
/// </summary>
public string? Name { get; internal set; }

/// <summary>
/// Gets or sets the label for the input.
/// </summary>
Expand Down Expand Up @@ -164,6 +172,174 @@ public int? MaxLength
internal List<string> ValidationErrors { get; } = [];
}

/// <summary>
/// A collection of interaction inputs that supports both indexed and name-based access.
/// </summary>
[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[DebuggerDisplay("Count = {Count}")]
public sealed class InteractionInputCollection : IReadOnlyList<InteractionInput>
{
private readonly IReadOnlyList<InteractionInput> _inputs;
private readonly IReadOnlyDictionary<string, InteractionInput> _inputsByName;

/// <summary>
/// Initializes a new instance of the <see cref="InteractionInputCollection"/> class.
/// </summary>
/// <param name="inputs">The collection of interaction inputs to wrap.</param>
public InteractionInputCollection(IReadOnlyList<InteractionInput> inputs)
{
// Create a new list with proper names assigned
var processedInputs = new List<InteractionInput>();
var inputsByName = new Dictionary<string, InteractionInput>(StringComparer.OrdinalIgnoreCase);
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

// First pass: collect explicit names and check for duplicates
foreach (var input in inputs)
{
if (!string.IsNullOrWhiteSpace(input.Name))
{
if (usedNames.Contains(input.Name))
{
throw new InvalidOperationException($"Duplicate input name '{input.Name}' found. Input names must be unique.");
}
usedNames.Add(input.Name);
}
}

// Second pass: create new inputs with generated names where needed
for (var i = 0; i < inputs.Count; i++)
{
var input = inputs[i];
string finalName;

if (!string.IsNullOrWhiteSpace(input.Name))
{
finalName = input.Name;
}
else
{
// Generate a unique name based on the label or index
var baseName = GenerateBaseName(input.Label);
finalName = baseName;
var suffix = 1;

while (usedNames.Contains(finalName))
{
finalName = $"{baseName}_{suffix}";
suffix++;
}

usedNames.Add(finalName);

input.Name = finalName;
}

processedInputs.Add(input);
inputsByName[finalName] = input;
}

_inputs = processedInputs;
_inputsByName = inputsByName;
}

private static string GenerateBaseName(string label)
{
if (string.IsNullOrWhiteSpace(label))
{
return "Input";
}

// Convert to a valid identifier-like name
var chars = label.ToCharArray();
var result = new System.Text.StringBuilder();

for (var i = 0; i < chars.Length; i++)
{
var c = chars[i];
if (char.IsLetterOrDigit(c))
{
result.Append(c);
}
else if (result.Length > 0 && result[result.Length - 1] != '_')
{
result.Append('_');
}
}

// Ensure we have a valid name
var name = result.ToString().Trim('_');
return string.IsNullOrEmpty(name) ? "Input" : name;
}

/// <summary>
/// Gets an input by its name.
/// </summary>
/// <param name="name">The name of the input.</param>
/// <returns>The input with the specified name.</returns>
/// <exception cref="KeyNotFoundException">Thrown when no input with the specified name exists.</exception>
public InteractionInput this[string name]
{
get
{
if (_inputsByName.TryGetValue(name, out var input))
{
return input;
}
throw new KeyNotFoundException($"No input with name '{name}' was found.");
}
}

/// <summary>
/// Gets an input by its index.
/// </summary>
/// <param name="index">The zero-based index of the input.</param>
/// <returns>The input at the specified index.</returns>
public InteractionInput this[int index] => _inputs[index];

/// <summary>
/// Gets the number of inputs in the collection.
/// </summary>
public int Count => _inputs.Count;

/// <summary>
/// Tries to get an input by its name.
/// </summary>
/// <param name="name">The name of the input.</param>
/// <param name="input">When this method returns, contains the input with the specified name, if found; otherwise, null.</param>
/// <returns>true if an input with the specified name was found; otherwise, false.</returns>
public bool TryGetByName(string name, [NotNullWhen(true)] out InteractionInput? input)
{
return _inputsByName.TryGetValue(name, out input);
}

/// <summary>
/// Determines whether the collection contains an input with the specified name.
/// </summary>
/// <param name="name">The name to locate in the collection.</param>
/// <returns>true if the collection contains an input with the specified name; otherwise, false.</returns>
public bool ContainsName(string name)
{
return _inputsByName.ContainsKey(name);
}

/// <summary>
/// Gets the names of all inputs in the collection.
/// </summary>
public IEnumerable<string> Names => _inputsByName.Keys;

/// <summary>
/// Returns an enumerator that iterates through the collection.
/// </summary>
/// <returns>An enumerator that can be used to iterate through the collection.</returns>
public IEnumerator<InteractionInput> GetEnumerator() => _inputs.GetEnumerator();

/// <summary>
/// Returns an enumerator that iterates through the collection.
/// </summary>
/// <returns>An enumerator that can be used to iterate through the collection.</returns>
IEnumerator IEnumerable.GetEnumerator() => _inputs.GetEnumerator();
}

/// <summary>
/// Specifies the type of input for an <see cref="InteractionInput"/>.
/// </summary>
Expand Down Expand Up @@ -217,7 +393,7 @@ public sealed class InputsDialogValidationContext
/// <summary>
/// Gets the inputs that are being validated.
/// </summary>
public required IReadOnlyList<InteractionInput> Inputs { get; init; }
public required InteractionInputCollection Inputs { get; init; }

/// <summary>
/// Gets the cancellation token for the validation operation.
Expand Down
15 changes: 9 additions & 6 deletions src/Aspire.Hosting/InteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,24 +88,27 @@ public async Task<InteractionResult<InteractionInput>> PromptInputAsync(string t
return InteractionResult.Ok(result.Data[0]);
}

public async Task<InteractionResult<IReadOnlyList<InteractionInput>>> PromptInputsAsync(string title, string? message, IReadOnlyList<InteractionInput> inputs, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default)
public async Task<InteractionResult<InteractionInputCollection>> PromptInputsAsync(string title, string? message, IReadOnlyList<InteractionInput> inputs, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default)
{
EnsureServiceAvailable();

cancellationToken.ThrowIfCancellationRequested();

options ??= InputsDialogInteractionOptions.Default;

var newState = new Interaction(title, message, options, new Interaction.InputsInteractionInfo(inputs), cancellationToken);
// Create the collection early to validate names and generate missing ones
var inputCollection = new InteractionInputCollection(inputs);

var newState = new Interaction(title, message, options, new Interaction.InputsInteractionInfo(inputCollection), cancellationToken);
AddInteractionUpdate(newState);

using var _ = cancellationToken.Register(OnInteractionCancellation, state: newState);

var completion = await newState.CompletionTcs.Task.ConfigureAwait(false);
var inputState = completion.State as IReadOnlyList<InteractionInput>;
return inputState == null
? InteractionResult.Cancel<IReadOnlyList<InteractionInput>>()
: InteractionResult.Ok(inputState);
? InteractionResult.Cancel<InteractionInputCollection>()
: InteractionResult.Ok(new InteractionInputCollection(inputState));
}

public async Task<InteractionResult<bool>> PromptNotificationAsync(string title, string message, NotificationInteractionOptions? options = null, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -433,12 +436,12 @@ public NotificationInteractionInfo(MessageIntent intent, string? linkText, strin

internal sealed class InputsInteractionInfo : InteractionInfoBase
{
public InputsInteractionInfo(IReadOnlyList<InteractionInput> inputs)
public InputsInteractionInfo(InteractionInputCollection inputs)
{
Inputs = inputs;
}

public IReadOnlyList<InteractionInput> Inputs { get; }
public InteractionInputCollection Inputs { get; }
}
}

Expand Down
28 changes: 26 additions & 2 deletions src/Aspire.Hosting/api/Aspire.Hosting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ public partial interface IInteractionService
System.Threading.Tasks.Task<InteractionResult<bool>> PromptConfirmationAsync(string title, string message, MessageBoxInteractionOptions? options = null, System.Threading.CancellationToken cancellationToken = default);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot revert this file, it is auto updated.

System.Threading.Tasks.Task<InteractionResult<InteractionInput>> PromptInputAsync(string title, string? message, InteractionInput input, InputsDialogInteractionOptions? options = null, System.Threading.CancellationToken cancellationToken = default);
System.Threading.Tasks.Task<InteractionResult<InteractionInput>> PromptInputAsync(string title, string? message, string inputLabel, string placeHolder, InputsDialogInteractionOptions? options = null, System.Threading.CancellationToken cancellationToken = default);
System.Threading.Tasks.Task<InteractionResult<System.Collections.Generic.IReadOnlyList<InteractionInput>>> PromptInputsAsync(string title, string? message, System.Collections.Generic.IReadOnlyList<InteractionInput> inputs, InputsDialogInteractionOptions? options = null, System.Threading.CancellationToken cancellationToken = default);
System.Threading.Tasks.Task<InteractionResult<InteractionInputCollection>> PromptInputsAsync(string title, string? message, System.Collections.Generic.IReadOnlyList<InteractionInput> inputs, InputsDialogInteractionOptions? options = null, System.Threading.CancellationToken cancellationToken = default);
System.Threading.Tasks.Task<InteractionResult<bool>> PromptMessageBoxAsync(string title, string message, MessageBoxInteractionOptions? options = null, System.Threading.CancellationToken cancellationToken = default);
System.Threading.Tasks.Task<InteractionResult<bool>> PromptNotificationAsync(string title, string message, NotificationInteractionOptions? options = null, System.Threading.CancellationToken cancellationToken = default);
}
Expand All @@ -364,7 +364,7 @@ public sealed partial class InputsDialogValidationContext
{
public required System.Threading.CancellationToken CancellationToken { get { throw null; } init { } }

public required System.Collections.Generic.IReadOnlyList<InteractionInput> Inputs { get { throw null; } init { } }
public required InteractionInputCollection Inputs { get { throw null; } init { } }

public required System.IServiceProvider ServiceProvider { get { throw null; } init { } }

Expand All @@ -381,6 +381,28 @@ public enum InputType
Number = 4
}

[System.Diagnostics.CodeAnalysis.Experimental("ASPIREINTERACTION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public sealed partial class InteractionInputCollection : System.Collections.Generic.IReadOnlyList<InteractionInput>, System.Collections.Generic.IEnumerable<InteractionInput>, System.Collections.IEnumerable
{
public InteractionInputCollection(System.Collections.Generic.IReadOnlyList<InteractionInput> inputs) { }

public int Count { get { throw null; } }

public System.Collections.Generic.IEnumerable<string> Names { get { throw null; } }

public InteractionInput this[int index] { get { throw null; } }

public InteractionInput this[string name] { get { throw null; } }

public bool ContainsName(string name) { throw null; }

public System.Collections.Generic.IEnumerator<InteractionInput> GetEnumerator() { throw null; }

System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; }

public bool TryGetByName(string name, out InteractionInput? input) { throw null; }
}

[System.Diagnostics.CodeAnalysis.Experimental("ASPIREINTERACTION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public sealed partial class InteractionInput
{
Expand All @@ -394,6 +416,8 @@ public sealed partial class InteractionInput

public int? MaxLength { get { throw null; } set { } }

public string? Name { get { throw null; } init { } }

public System.Collections.Generic.IReadOnlyList<System.Collections.Generic.KeyValuePair<string, string>>? Options { get { throw null; } init { } }

public string? Placeholder { get { throw null; } set { } }
Expand Down
Loading
Loading