File: Components\Controls\ResourceTagEditor.razor.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;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.Localization;
using Microsoft.JSInterop;
 
namespace Aspire.Dashboard.Components.Controls;
 
/// <summary>
/// Edits the tags of a resource. Tags are displayed as chips, and new tags are typed in a combobox that suggests the
/// tags of other resources so tags are reused rather than duplicated with a different spelling.
/// </summary>
public sealed partial class ResourceTagEditor : ComponentBase, IDisposable
{
    private readonly string _id = $"tag-editor-{Guid.NewGuid():N}";
    private IReadOnlyList<string> _tags = [];
    private List<TagOption> _options = [];
    private string _text = string.Empty;
    private bool _isEditing;
    private int _activeIndex = -1;
    private FocusTarget _pendingFocus;
    private ElementReference _input;
    private ElementReference _addButton;
 
    /// <summary>
    /// Gets or sets the display name of the resource, which tags are keyed by.
    /// </summary>
    [Parameter, EditorRequired]
    public required string ResourceName { get; set; }
 
    /// <summary>Gets or sets the AppHost-defined tags that cannot be removed from the dashboard.</summary>
    [Parameter]
    public IReadOnlyList<string> AppHostTags { get; set; } = [];
 
    [Parameter]
    public IReadOnlyList<string> AvailableTags { get; set; } = [];
 
    [Inject]
    public required ResourceTagStore TagStore { get; init; }
 
    [Inject]
    public required IStringLocalizer<Resources.Resources> Loc { get; init; }
 
    private string ListboxId => $"{_id}-listbox";
 
    protected override void OnInitialized()
    {
        TagStore.Changed += OnTagsChanged;
    }
 
    protected override void OnParametersSet()
    {
        UpdateTags();
        UpdateOptions();
    }
 
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        var pendingFocus = _pendingFocus;
        _pendingFocus = FocusTarget.None;
 
        try
        {
            if (pendingFocus == FocusTarget.Input && _isEditing)
            {
                await _input.FocusAsync();
            }
            else if (pendingFocus == FocusTarget.AddButton && !_isEditing)
            {
                await _addButton.FocusAsync();
            }
        }
        catch (JSDisconnectedException)
        {
            // The circuit disconnected. There is nothing to focus.
        }
    }
 
    private string GetOptionId(int index) => $"{_id}-option-{index}";
 
    private void OnTagsChanged()
    {
        // Tags can change from another browser tab or the resource list, and the event can be raised on any thread.
        _ = InvokeAsync(() =>
        {
            UpdateTags();
            UpdateOptions();
            StateHasChanged();
        });
    }
 
    private void StartEditing()
    {
        _isEditing = true;
        _text = string.Empty;
        _pendingFocus = FocusTarget.Input;
        UpdateOptions();
    }
 
    private void CloseEditor()
    {
        _isEditing = false;
        _text = string.Empty;
        UpdateOptions();
    }
 
    private void UpdateOptions()
    {
        if (!_isEditing)
        {
            _options = [];
            _activeIndex = -1;
            return;
        }
 
        var text = ResourceTagStore.NormalizeTag(_text);
        var allTags = AvailableTags.Concat(AppHostTags).Concat(TagStore.GetAllTags())
            .Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.CurrentCultureIgnoreCase).ToArray();
 
        // Existing tags are suggested first, with the closest matches at the top, so typing part of a tag and
        // pressing Enter reuses it.
        var options = allTags
            .Where(t => !_tags.Contains(t, StringComparer.OrdinalIgnoreCase))
            .Where(t => text is null || t.Contains(text, StringComparisons.UserTextSearch))
            .OrderBy(t => text is not null && string.Equals(t, text, StringComparison.OrdinalIgnoreCase) ? 0 : text is not null && t.StartsWith(text, StringComparisons.UserTextSearch) ? 1 : 2)
            .Select(t => new TagOption(t, IsNew: false))
            .ToList();
 
        if (text is not null && !allTags.Contains(text, StringComparer.OrdinalIgnoreCase))
        {
            options.Add(new TagOption(text, IsNew: true));
        }
 
        _options = options;
 
        // Nothing is active until the user types or uses the arrow keys, so pressing Enter in an empty input
        // doesn't add a suggestion the user didn't choose.
        _activeIndex = text is null || _options.Count == 0 ? -1 : 0;
    }
 
    private void OnKeyDown(KeyboardEventArgs e)
    {
        switch (e.Key)
        {
            case "ArrowDown" when _options.Count > 0:
                _activeIndex = (_activeIndex + 1) % _options.Count;
                break;
            case "ArrowUp" when _options.Count > 0:
                _activeIndex = _activeIndex <= 0 ? _options.Count - 1 : _activeIndex - 1;
                break;
            case "Enter":
                if (_activeIndex >= 0 && _activeIndex < _options.Count)
                {
                    AddTag(_options[_activeIndex].Tag);
                }
                else if (ResourceTagStore.NormalizeTag(_text) is { } tag)
                {
                    AddTag(tag);
                }
                break;
            case "Escape":
                CloseEditor();
                _pendingFocus = FocusTarget.AddButton;
                break;
            case "Backspace" when _text.Length == 0 && _tags.Count > 0:
                // Like other token inputs, Backspace in an empty input removes the last tag.
                if (_tags.LastOrDefault(t => !IsAppHostTag(t)) is { } removableTag)
                {
                    RemoveTag(removableTag);
                }
                break;
        }
    }
 
    private void AddTag(string tag)
    {
        if (!IsAppHostTag(tag))
        {
            TagStore.AddTag(ResourceName, tag);
        }
        UpdateTags();
        _text = string.Empty;
        _pendingFocus = FocusTarget.Input;
        UpdateOptions();
    }
 
    private void RemoveTag(string tag)
    {
        if (IsAppHostTag(tag))
        {
            return;
        }
        TagStore.RemoveTag(ResourceName, tag);
        UpdateTags();
        UpdateOptions();
    }
 
    private bool IsAppHostTag(string tag) => AppHostTags.Contains(tag, StringComparer.OrdinalIgnoreCase);
 
    private void UpdateTags() =>
        _tags = AppHostTags.Concat(TagStore.GetTags(ResourceName)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
 
    public void Dispose()
    {
        TagStore.Changed -= OnTagsChanged;
    }
 
    private enum FocusTarget
    {
        None,
        Input,
        AddButton
    }
 
    private sealed record TagOption(string Tag, bool IsNew);
}