Skip to content

Commit 65e3581

Browse files
CopilotJamesNK
andcommitted
Add Name property and InteractionInputCollection with comprehensive tests
Co-authored-by: JamesNK <[email protected]>
1 parent 10efd74 commit 65e3581

File tree

4 files changed

+489
-11
lines changed

4 files changed

+489
-11
lines changed

src/Aspire.Hosting/IInteractionService.cs

Lines changed: 187 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Collections;
45
using System.Diagnostics.CodeAnalysis;
56

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

8788
/// <summary>
8889
/// Prompts the user with a notification.
@@ -103,6 +104,12 @@ public interface IInteractionService
103104
[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
104105
public sealed class InteractionInput
105106
{
107+
/// <summary>
108+
/// Gets or sets the name for the input. Used for accessing inputs by name from a keyed collection.
109+
/// If not specified, a name will be generated automatically.
110+
/// </summary>
111+
public string? Name { get; init; }
112+
106113
/// <summary>
107114
/// Gets or sets the label for the input.
108115
/// </summary>
@@ -164,6 +171,183 @@ public int? MaxLength
164171
internal List<string> ValidationErrors { get; } = [];
165172
}
166173

174+
/// <summary>
175+
/// A collection of interaction inputs that supports both indexed and name-based access.
176+
/// </summary>
177+
[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
178+
public sealed class InteractionInputCollection : IReadOnlyList<InteractionInput>
179+
{
180+
private readonly IReadOnlyList<InteractionInput> _inputs;
181+
private readonly IReadOnlyDictionary<string, InteractionInput> _inputsByName;
182+
183+
internal InteractionInputCollection(IReadOnlyList<InteractionInput> inputs)
184+
{
185+
// Create a new list with proper names assigned
186+
var processedInputs = new List<InteractionInput>();
187+
var inputsByName = new Dictionary<string, InteractionInput>(StringComparer.OrdinalIgnoreCase);
188+
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
189+
190+
// First pass: collect explicit names and check for duplicates
191+
foreach (var input in inputs)
192+
{
193+
if (!string.IsNullOrWhiteSpace(input.Name))
194+
{
195+
if (usedNames.Contains(input.Name))
196+
{
197+
throw new InvalidOperationException($"Duplicate input name '{input.Name}' found. Input names must be unique.");
198+
}
199+
usedNames.Add(input.Name);
200+
}
201+
}
202+
203+
// Second pass: create new inputs with generated names where needed
204+
for (int i = 0; i < inputs.Count; i++)
205+
{
206+
var input = inputs[i];
207+
string finalName;
208+
209+
if (!string.IsNullOrWhiteSpace(input.Name))
210+
{
211+
finalName = input.Name;
212+
}
213+
else
214+
{
215+
// Generate a unique name based on the label or index
216+
string baseName = GenerateBaseName(input.Label);
217+
finalName = baseName;
218+
int suffix = 1;
219+
220+
while (usedNames.Contains(finalName))
221+
{
222+
finalName = $"{baseName}_{suffix}";
223+
suffix++;
224+
}
225+
226+
usedNames.Add(finalName);
227+
}
228+
229+
// Create a new input with the final name if it was generated
230+
var finalInput = string.IsNullOrWhiteSpace(input.Name) ?
231+
new InteractionInput
232+
{
233+
Name = finalName,
234+
Label = input.Label,
235+
Description = input.Description,
236+
EnableDescriptionMarkdown = input.EnableDescriptionMarkdown,
237+
InputType = input.InputType,
238+
Required = input.Required,
239+
Options = input.Options,
240+
Value = input.Value,
241+
Placeholder = input.Placeholder,
242+
MaxLength = input.MaxLength
243+
} : input;
244+
245+
processedInputs.Add(finalInput);
246+
inputsByName[finalName] = finalInput;
247+
}
248+
249+
_inputs = processedInputs;
250+
_inputsByName = inputsByName;
251+
}
252+
253+
private static string GenerateBaseName(string label)
254+
{
255+
if (string.IsNullOrWhiteSpace(label))
256+
{
257+
return "Input";
258+
}
259+
260+
// Convert to a valid identifier-like name
261+
var chars = label.ToCharArray();
262+
var result = new System.Text.StringBuilder();
263+
264+
for (int i = 0; i < chars.Length; i++)
265+
{
266+
char c = chars[i];
267+
if (char.IsLetterOrDigit(c))
268+
{
269+
result.Append(c);
270+
}
271+
else if (result.Length > 0 && result[result.Length - 1] != '_')
272+
{
273+
result.Append('_');
274+
}
275+
}
276+
277+
// Ensure we have a valid name
278+
string name = result.ToString().Trim('_');
279+
return string.IsNullOrEmpty(name) ? "Input" : name;
280+
}
281+
282+
/// <summary>
283+
/// Gets an input by its name.
284+
/// </summary>
285+
/// <param name="name">The name of the input.</param>
286+
/// <returns>The input with the specified name.</returns>
287+
/// <exception cref="KeyNotFoundException">Thrown when no input with the specified name exists.</exception>
288+
public InteractionInput this[string name]
289+
{
290+
get
291+
{
292+
if (_inputsByName.TryGetValue(name, out var input))
293+
{
294+
return input;
295+
}
296+
throw new KeyNotFoundException($"No input with name '{name}' was found.");
297+
}
298+
}
299+
300+
/// <summary>
301+
/// Gets an input by its index.
302+
/// </summary>
303+
/// <param name="index">The zero-based index of the input.</param>
304+
/// <returns>The input at the specified index.</returns>
305+
public InteractionInput this[int index] => _inputs[index];
306+
307+
/// <summary>
308+
/// Gets the number of inputs in the collection.
309+
/// </summary>
310+
public int Count => _inputs.Count;
311+
312+
/// <summary>
313+
/// Tries to get an input by its name.
314+
/// </summary>
315+
/// <param name="name">The name of the input.</param>
316+
/// <param name="input">When this method returns, contains the input with the specified name, if found; otherwise, null.</param>
317+
/// <returns>true if an input with the specified name was found; otherwise, false.</returns>
318+
public bool TryGetByName(string name, out InteractionInput? input)
319+
{
320+
return _inputsByName.TryGetValue(name, out input);
321+
}
322+
323+
/// <summary>
324+
/// Determines whether the collection contains an input with the specified name.
325+
/// </summary>
326+
/// <param name="name">The name to locate in the collection.</param>
327+
/// <returns>true if the collection contains an input with the specified name; otherwise, false.</returns>
328+
public bool ContainsName(string name)
329+
{
330+
return _inputsByName.ContainsKey(name);
331+
}
332+
333+
/// <summary>
334+
/// Gets the names of all inputs in the collection.
335+
/// </summary>
336+
public IEnumerable<string> Names => _inputsByName.Keys;
337+
338+
/// <summary>
339+
/// Returns an enumerator that iterates through the collection.
340+
/// </summary>
341+
/// <returns>An enumerator that can be used to iterate through the collection.</returns>
342+
public IEnumerator<InteractionInput> GetEnumerator() => _inputs.GetEnumerator();
343+
344+
/// <summary>
345+
/// Returns an enumerator that iterates through the collection.
346+
/// </summary>
347+
/// <returns>An enumerator that can be used to iterate through the collection.</returns>
348+
IEnumerator IEnumerable.GetEnumerator() => _inputs.GetEnumerator();
349+
}
350+
167351
/// <summary>
168352
/// Specifies the type of input for an <see cref="InteractionInput"/>.
169353
/// </summary>
@@ -217,7 +401,7 @@ public sealed class InputsDialogValidationContext
217401
/// <summary>
218402
/// Gets the inputs that are being validated.
219403
/// </summary>
220-
public required IReadOnlyList<InteractionInput> Inputs { get; init; }
404+
public required InteractionInputCollection Inputs { get; init; }
221405

222406
/// <summary>
223407
/// Gets the cancellation token for the validation operation.

src/Aspire.Hosting/InteractionService.cs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,24 +88,27 @@ public async Task<InteractionResult<InteractionInput>> PromptInputAsync(string t
8888
return InteractionResult.Ok(result.Data[0]);
8989
}
9090

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

9595
cancellationToken.ThrowIfCancellationRequested();
9696

9797
options ??= InputsDialogInteractionOptions.Default;
9898

99-
var newState = new Interaction(title, message, options, new Interaction.InputsInteractionInfo(inputs), cancellationToken);
99+
// Create the collection early to validate names and generate missing ones
100+
var inputCollection = new InteractionInputCollection(inputs);
101+
102+
var newState = new Interaction(title, message, options, new Interaction.InputsInteractionInfo(inputCollection), cancellationToken);
100103
AddInteractionUpdate(newState);
101104

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

104107
var completion = await newState.CompletionTcs.Task.ConfigureAwait(false);
105108
var inputState = completion.State as IReadOnlyList<InteractionInput>;
106109
return inputState == null
107-
? InteractionResult.Cancel<IReadOnlyList<InteractionInput>>()
108-
: InteractionResult.Ok(inputState);
110+
? InteractionResult.Cancel<InteractionInputCollection>()
111+
: InteractionResult.Ok(new InteractionInputCollection(inputState));
109112
}
110113

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

434437
internal sealed class InputsInteractionInfo : InteractionInfoBase
435438
{
436-
public InputsInteractionInfo(IReadOnlyList<InteractionInput> inputs)
439+
public InputsInteractionInfo(InteractionInputCollection inputs)
437440
{
438441
Inputs = inputs;
439442
}
440443

441-
public IReadOnlyList<InteractionInput> Inputs { get; }
444+
public InteractionInputCollection Inputs { get; }
442445
}
443446
}
444447

0 commit comments

Comments
 (0)