Многоразовый универсальный компонент автозаполнения для Blazor.

  • Михаил
  • 12 мин. на прочтение
  • 119
  • 24 Nov 2022
  • 24 Nov 2022

В одной из последних статей я рассмотрел создание простого компонента автозаполнения в приложении Blazor WebAssembly, который динамически извлекает данные из базы данных через API в зависимости от того, что пользователь вводит на входе. Он работает хорошо, но он не предназначен для повторного использования. В этой статье я рассмотрю шаги, необходимые для преобразования компонента, чтобы его можно было подключить в любом месте приложения и работать с любыми данными.

 

Во-первых, я рассмотрю функциональность компонента, который я представил в прошлой статье.

  1. Компонент имеет элемент управления вводом
  2. Обработчик события HandleInputсвязан с входным событием элемента управления.
  3. По мере того, как пользователь вводит текст, выполняется обработчик событий.
  4. Обработчик проверяет длину входного значения. Если длина превышает 2, выполняется запрос к API, который возвращает всех клиентов, имена которых включают входное значение.
  5. Клиенты, возвращенные API, отображаются в неупорядоченном списке, расположенном сразу под входными данными.
  6. Если пользователь выбирает клиента, щелкнув его, SelectItemсрабатывает метод, подключенный к событию щелчка элемента списка, .
  7. Имя выбранного клиента присваивается входным данным, а его имя и значение ключа отображаются как часть подтверждающего сообщения.
  8. Существующие данные из API аннулируются, а неупорядоченный список удаляется из пользовательского интерфейса.

Теперь я сосредоточусь на факторах, препятствующих повторному использованию существующего компонента в других частях приложения. Давайте рассмотрим раздел кода, где определяются данные и поведение компонента:

@code {
   List<Customer>? customers;
   string? selectedCustomerId;
   string? selectedCustomerName;
   string? filter;

   async Task HandleInput(ChangeEventArgs e)
   {
       filter = e.Value?.ToString();
       if (filter?.Length > 2)
       {
           customers = await http.GetFromJsonAsync<List<Customer>>($"/api/companyfilter?filter={filter}");
       }
       else
       {
           customers = null;
           selectedCustomerName = selectedCustomerId = null;
       }
   }

   void SelectCustomer(string id)
   {
       selectedCustomerId = id;
       selectedCustomerName = customers!.First(c => c.CustomerId.Equals(selectedCustomerId)).CompanyName;
       customers = null;
   }
}

Основная проблема, которая потенциально препятствует использованию этого компонента в другом месте приложения, заключается в его зависимости от одного конкретного типа данных — Customer. Если мы хотим предоставить службу автозаполнения, которая работает Product, например, с типом данных, нам нужно ее переписать. Компонент также зависит от конкретной жестко заданной конечной точки API. В идеале мы хотим, чтобы вызывающий компонент передал эти две части данных. Конечная точка API обрабатывается просто — в конце концов, это всего лишь строка. Сначала я создам компонент Razor в файле с именем AutocompleteComponent.razor , а затем добавлю параметр в блок кода для обработки URL-адреса API. Я также добавил EditorRequiredатрибут, чтобы среда IDE выдавала предупреждение, если для параметра не указано значение:

[Parameter, EditorRequired] public string? ApiUrl { get; set; }

Тип данных - другое дело.

Создавая компонент автозаполнения, мы понятия не имеем, с какими типами данных захотят работать потребители. Нам нужно разместить любой тип данных. Для этого мы используем поддержку компонентов универсального типа Razor . По сути, параметр универсального типа действует как заполнитель для типа, который передается вызывающим компонентом. Параметр универсального типа добавляется в верхнюю часть компонента Razor с помощью typeparamдирективы:

@typeparam TItem

Компонент автозаполнения будет отвечать за создание фактических данных в ответ на ввод данных пользователем в элемент управления формы, но нам также необходимо иметь возможность манипулировать этими данными извне компонента. Например, в текущем компоненте мы устанавливаем данные, nullчтобы очистить список параметров из пользовательского интерфейса. Итак, нам нужно добавить контейнер для данных, но нам также нужно сделать его публичным свойством и добавить Parameterк нему атрибут:

[Parameter, EditorRequired] public IEnumerable<TItem>? Items{ get; set; }

Давайте взглянем на фрагмент разметки в исходном компоненте. Он отображает детали выбранного элемента, который в данном случае является покупателем:

@if (!string.IsNullOrWhiteSpace(selectedCustomerName))
{
   <p class="mt-3">
       Selected customer is @selectedCustomerName with ID <strong>@selectedCustomerId</strong>
   </p>
}

Теперь, когда наш компонент является общим, такие ссылки, как «Выбранный клиент», больше не имеют смысла. Во время разработки мы не знаем, что потребитель может захотеть отобразить в пользовательском интерфейсе в случае выбора элемента, если вообще что-либо, поэтому мы оставим это на усмотрение потребителя, добавив еще один параметр в компонент, тип которого будет RenderFragment- представляющий фрагмент кода Razor для обработки и рендеринга:

[Parameterpublic RenderFragment? ResultsTemplate { getset; }

Вот еще один фрагмент разметки исходного компонента. Он отвечает за рендеринг данных, возвращаемых API в виде опций, и за добавление обработчика событий для события клика каждой опции:

@if (customers is not null)
{
   <ul class="options">
       @if (customers.Any())
       {
           @foreach (var customer in customers)
           {
               <li class="option" @onclick=@(_ => SelectCustomer(customer.CustomerId))>
                   <span class="option-text">@customer.CompanyName</span>
               </li>
           }
       }
       else
       {
           <li class="disabled option">No results</li>
       }
   </ul>
}

У нас уже есть замена для customersв новом компоненте — Items. Таким образом, мы можем довольно легко заменить большую часть этого кода:

@if (Items is not null)
{
   <ul class="options">
       @if (Items.Any())
       {
           @foreach (var item in Items)
           {
               @* TODO: Render Options *@
           }
       }
       else
       {
           <li class="disabled option">No results</li>
       }
   </ul>
}

Нам все еще нужно решить, как отображать элементы в качестве параметров и как реагировать на событие щелчка параметра. В исходном компоненте реализация обеих этих задач зависит от типа, а тип известен только вызывающему компоненту, поэтому мы переложим обе задачи на вызывающий. Во-первых, мы позволим вызывающему компоненту указать, как отображается каждый параметр, поскольку он знает о свойствах типа данных параметра, а компонент автозаполнения — нет. Поэтому мы предоставим еще один RenderFragment параметр, за исключением того, что на этот раз мы будем использовать версию с общим типом, которая принимает параметр:

[Parameter, EditorRequired] public RenderFragment<TItem> OptionTemplate{ get; set; } = default!;

Далее мы займемся обработчиком события клика, который в настоящее время назначен li элементу, содержащему каждую опцию. Если вы хотите, чтобы вызывающий компонент уведомлялся о событиях, происходящих в дочернем компоненте, вы делаете это с помощью EventCallbackпараметра, который представляет делегат, вызываемый в родительском компоненте. Мы будем использовать строго типизированный EventCallback<TValue>, который позволяет вызывающему компоненту указать тип данных, которые будут переданы в функцию обратного вызова родительского компонента. Мы добавляем EventCallback<TValue>параметр с именем OnSelectItem:

[Parameter, EditorRequired] public EventCallback<TItem> OnSelectItem { get; set; }

Нам нужен способ вызвать OnSelectItemобратный вызов события. Мы добавляем метод к компоненту, который принимает в TItemкачестве параметра и передает его InvokeAsyncметоду обратного вызова:

async Task SelectItem(TItem item) => await OnSelectItem.InvokeAsync(item);

Теперь код, отображающий каждую опцию, можно вставить в foreachцикл, который еще предстоит завершить:

@foreach (var item in Items)
{
   <li class="option" @onclick="_ => SelectItem(item)">
       @OptionTemplate(item)
   </li>
}

Мы почти на месте. В пункте 7 существующего функционала устанавливаем в качестве входных данных название выбранного товара. Мы позволим вызывающему компоненту установить это значение — если он захочет, предоставив параметр и привязав его к входным данным:

[Parameter] public string? SelectedValue { get; set; }
<input @bind=SelectedValue @oninput=HandleInput class="form-control filter" />

Наконец, мы реорганизуем HandleInputобработчик события клика, чтобы он работал с ApiUrl параметром и общими данными:

async Task HandleInput(ChangeEventArgs e)
{
   filter = e.Value?.ToString();
   if (filter?.Length > 2)
   {
       Items = await http.GetFromJsonAsync<IEnumerable<TItem>>($"{ApiUrl}{filter}");
   }
   else
   {
       Items = null;
   }
}

Использование компонента

Теперь, когда мы создали компонент, мы можем использовать его в другом компоненте. В загружаемом файле, сопровождающем эту статью, я использовал его дважды на одной и той же странице — один раз для получения списка клиентов, а второй — для получения списка продуктов.

Многоразовый компонент автозаполнения

Здесь я покажу только шаги, необходимые для работы с данными клиентов. Во-первых, вот блок кода в вызывающем компоненте. Он определяет поля для данных клиента и выбранного клиента. Он также включает метод с именем SelectCustomer, который будет передан как делегат к EventCallbackпараметру:

@code {
   List<Customer>? Customers;
   Customer? SelectedCustomer;
   void SelectCustomer(Customer customer)
   {
       SelectedCustomer = customer;
       Customers = null;
   }
}

Вот открывающий тег для компонента. Мы передаем значения для Itemsпараметров SelectedValueи ApiUrl. Мы также передаем имя SelectCustomerметода в качестве делегата к OnSelectItemпараметру. И мы передаем параметр, Customerчтобы TItemкомпонент автозаполнения знал, какой тип передать обратно в обратный вызов события:

<AutocompleteComponent Items="Customers"
                      SelectedValue="@(SelectedCustomer?.CompanyName)" 
                      OnSelectItem="SelectCustomer" 
                      TItem="Customer"
                      Context="customer"
                      ApiUrl="/api/companyfilter?filter=">

Было передано еще одно значение; Context Параметру был присвоен "клиент" . Это устанавливает имя выражения, используемого в строго типизированных RenderFragmentпараметрах. У нас есть один из них — OptionTemplateпараметр, который добавляется внутри открывающего и закрывающего AutocompleteComponentтегов:

<OptionTemplate>
   <span class="option-text">@customer.CompanyName</span>
</OptionTemplate>

Мы также можем предоставить содержимое для ResultsTemplateпараметра перед закрывающим AutocompleteComponentтегом, если хотим:

    <ResultsTemplate>
       @if (SelectedCustomer != null)
       {
           <p class="mt-3">
               Selected customer is <strong>@SelectedCustomer.CompanyName</strong>
               with ID <strong>@SelectedCustomer.CustomerId</strong>
           </p>
       }
   </ResultsTemplate>
</AutocompleteComponent>

Резюме

Если мне нужно использовать функцию автозаполнения в нескольких местах в приложении Blazor, мне больше не нужно копировать и вставлять один и тот же код. Я могу централизовать его в одном повторно используемом и более удобном в сопровождении компоненте и делегировать решения по данным и некоторым аспектам поведения вызывающим компонентам с использованием @typeparamстрого типизированных EventCallbackи RenderFragment параметров.

Код этой статьи доступен на Github .