Blazor Autocomplete für ASP.NET core

Wenn der Benutzer nach Orten sucht, wird ihm eine Liste von passenden Orten vorgeschlagen. Eine Funktion die man als Autocomplete bezeichnet und im Web in jedem Fall JavaScript und ziemlich sicher einen AJAX Callback erfordert.

Die Idee ist diese Funktion durch Blazor zu ersetzen, aber so das es in einer herkömmlichen Seite weiter genutzt werden kann. Theoretisch sogar in einer ASPX Webforms Seite mit einer Blazor WebAssembly Komponente. Zum Start reduziere ich das Beispiel auf eine blutsverwandte Razor Page.

   1:  @page
   2:  @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
   3:  @model TypedComponent.Pages.UseBlazoreModel
   4:  <form method="post">
   5:      <input name="Name" />
   6:      <input type="submit" value="ok" />
   7:  </form>

Das Razor Modelbinding wertet die HTML Methoden aus um das Routing zur Methode vorzunehmen. Alles per Konvention.

   1:  [BindProperty]
   2:  public string Name { get; set; }
   3:  public void OnPost()
   4:          {

Welche Möglichkeiten ergeben sich hier theoretisch einzugreifen um Autocomplete ergänzend zu erhalten?

  • HTML Attribut ins Input
  • Custom Input Component
  • extra Input Extension Component

Vielleicht sieht der Leser weitere Optionen. Ich habe mich vorerst für eine Blazor Component entschieden. Im selben Projekt liegt eine Razor cshtml Datei und die Compionent .Razor. Beides im Pages Verzeichnis. Das alles rein um einen Prototypen zu bauen.

In der _hosts.cshtml wird die Blazor App per Component TagHelper eingebunden. Den gleichen “Trick” kann man in beliebiger Razor Page verwenden mit direkter Referenz auf die Blazor Component. Außerdem wird der Blazor Bootloader benötigt, eingebunden per Script in Zeile 19.

   1:  @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
   2:  @model TypedComponent.Pages.UseBlazoreModel
   3:  <html>
   4:  <head>
   5:      <link href="~/css/bootstrap/bootstrap.min.css" rel="stylesheet" />
   6:  </head>
   7:  <body>
   8:      <form method="post">
   9:          @*<input name="Name" />*@
  10:          <component type="typeof(TypedComponent.Pages.AutoComplete)"
  11:                     render-mode="ServerPrerendered" />
  12:          <input type="submit" value="ok" />
  13:      </form>
  14:      @Model.Name<br />
  15:  </body>
  16:  </html>
  17:   
  18:   
  19:  <script src="_framework/blazor.server.js"></script>

So einfach kann Blazor in eine ASP.NET core Seite eingebunden werden.

Zeile 9 war vorher das Input Element, das nun von Zeile 10 der Blazor Komponente gleichnamig gerdendet werden muss. Hier nutze ich von Bootstrap die CSS Layout Struktur des Drop Down Menüs. Um eine Adhoc Aktualisierung des Bindings zu bewirken kommt Event on input hinzu im Input Element.

Wenn der Benutzer das Element klickt, soll der ausgewählte Wert in der Textbox also dem Input Element stehen. Gelöst per Lambda Expression im onclick der Liste der gefundenen Einträge.

   1:  <div>
   2:      <input name="Name" @bind-value="Eingabe" 
@bind-value:event="oninput" />
   3:      @if (VorschlagListe != null)
   4:      {
   5:          <div class="dropdown-menu show" 

style="position: absolute; transform: translate3d(
-11px, 38px, 0px); top: 0px; left: 0px; will-change: transform;"
>
   6:              @foreach (var item in VorschlagListe)
   7:              {
   8:                  <a class="dropdown-item " @onclick="(()=>Eingabe=item)"
   9:                     @onclick:preventDefault>@item</a>
  10:              }
  11:          </div>
  12:      }
  13:  </div>

 

Der C# Code hierzu dient nur um die Funktion überhaupt prüfen zu können. Die Eingabe füllt eine Vorschlagsliste im Setter der Property.

   1:  private string _eingabe;
   2:  public string Eingabe
   3:  {
   4:    get { return _eingabe; }
   5:    set
   6:    {
   7:     _eingabe = value;
   8:     var a = _eingabe.ToCharArray().ToList();
   9:     VorschlagListe = a.Select(c => c.ToString()).ToList();
  10:    }
  11:      }
  12:  public List<string> VorschlagListe { get; set; }

Das probieren wir doch gleich aus und tippe “Sepp” in das Eingabe Feld. Es wird eine Liste angezeigt mit den einzelnen Buchstaben. Man kann einen Eintrag auswählen und der steht dann im Eingabefeld.

.sepp

Nicht perfekt, aber fürs erste ok. Gefixt wird später. Nun muss das Ding universeller werden. Sehr lange habe ich über das Problem der Daten nachgedacht. Eine Komponente soll ja eine Black Box sein und wird nur per Parameter gesteuert. Das klappt ganz gut für einfache oder Komplexe Objekte die von außen eingestreut werden, In der Regel wird erst ab 2 oder 3 Buchstaben die Vorschlagsliste gefüllt. Dazu legt man in der Blazor Component ein Property Minlenght an und versieht es mit dem Paramater Attribut

   1:  [Parameter]
   2:  public int MinLength { get; set; }

Der Aufrufende Component Tag Helper aus der Razor Page kann dieses Property nicht direkt ansprechen sondern über eine param- Notation. Würde man Blazor zu Blazor machen, wäre es natürlich nur der Parameter direkt im Element Attribut.

Also die cshtml Razor Page ruft eine Blazor Komponente für den ajax Callback wie folgt mit Attribut auf

   1:  <component  param-MinLength="2"

Um komplexere Objekte und ein String zählt da schon dazu weiter reichen zu können, muss man glatt einen Code Block in der Attribut Zuweisung nutzen. Eingeleitet wird so etwas wie üblich mit @.

Im HTML Element Input gibt es eine Reihe von Attributen wie Inputmode, Style oder Class. Um nicht für jedes davon und alle anderen jeweils ein Property in der Blazor Komponentenklasse anlegen zu müssen, kann man das pauschal definieren. Dazu legt man einen Parameter vom Typ Dictionary an

   1:   [Parameter(CaptureUnmatchedValues = true)]
   2:   public Dictionary<string, object> InputAttributes { get; set; }

Dieser wird im HTML Part der Blazor Komponente einmal für alles deklariert. Die Eigenschaft der Blazor View Engine @attributes

   1:   <input name="Name"  @attributes="InputAttributes"

Der Aufrufer im Razor View per Component Tag Helper hat dann ein wenig mehr Mühe per Code das Dictionary zu füllen.

  <component param-InputAttributes='@new Dictionary<string, object>() 
{{ "placeholder", "schreib was" },{ "class", "form-control" }}'
      

Nicht wunderschöner HTML Code, aber noch die Absicht erkennbar.

Soweit zu den Grundlagen der Parametrisierung des Razor Component Tag Helpers. Nun stellte sich mir die Frage (hatte ich das schon geschrieben?) wo kommen die Daten für die Vorschlagsliste her? Am Ende habe ich mich an die Konzepte von Web Anwendungen gehalten. Eine SPA Web App holt sich Daten per Http Call meist im Json Format von einer REST API. Vorteil ist das der Aufrufer (die Razor Page) nicht nur steuert, welche Logik genutzt wird, sondern auch welcher Code genau. Um das zu erläutern, werfen wir einen Blick in das Pagemodel (dem Codebehind) der Razor Page.
Statt einer Web Api wird ein Razor PageHandler mit Suchparameter genutzt um Json Daten zurück zu geben. Aufgerufen wird die mini Api mit RazorPage/List?suche=audi.

   1:  public JsonResult OnGetList([FromServices] CarsService _carService, string suche)
   2:   {
   3:    var ret = new List<string>();
   4:    if (suche != null)
   5:     {
   6:      var q = _carService.CarListe.Where(
x => x.ToLower().Contains(suche.ToLower())).Take(10);
   7:      return new JsonResult(q);
   8:      }
   9:     return new JsonResult(ret);
  10:  }

Die eigentlich Datenzugriffslogik wird noch einmal weg gekapselt. Eine Json Datei dient als Fake Datenbank.

   1:  [
   2:    "Abarth",
   3:    "Alfa Romeo",
   4:    "Aston Martin",

In meinem Visual Studio Projekt liegt die CarService.cs im Data Verzeichnis.

   1:  public class CarsService
   2:  {
   3:   public List<String> CarListe { get; set; }
   4:   public CarsService()
   5:    {
   6:    var pfad = AppDomain.CurrentDomain.GetData("WWWBaseDirectory").ToString() +
   7:                    "\\wwwroot\\cars.json";
   8:    var json = System.IO.File.ReadAllText(pfad);
   9:    CarListe = JsonSerializer.Deserialize<String[]>(json).ToList();  
  10:   }
  11:  }

Für die Registrierung des DatenServices im Dependency Injection Container und der Pfadsache wenden wir uns der startup.cs zu. Nicht nur der CarService sondern auch für Http muss die Service Collection erweitert werden.

   1:  public void ConfigureServices(IServiceCollection services)
   2:  {
   3:     services.AddSingleton<CarsService>();
   4:     services.AddHttpClient();

Die einzige Stelle an der Zugriff auf den Pfad möglich ist, indem die App läuft

   1:  public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
   2:  {
   3:     AppDomain.CurrentDomain.SetData("WWWBaseDirectory",
env.ContentRootPath);

Statische Dateien werden in das Verzeichnis wwwroot gelegt.

Den Service kann auch direkt im Browser getestet werden https://localhost:44382/useblazore/List?suche=a. Der Port hängt natürlich von Ihrer Umgebung ab.

Das alles zusammengestöpselt in der Userblazore.cshtnl Razor View.

   1:   <form method="post">
   2:      @*<input name="Name" />*@
   3:      <component param-InputAttributes='
@new Dictionary<string, object>() {{ "placeholder", "schreib was" },{ "class", "form-control" }}'
   4:     param-MinLength="2"
   5:     param-LookupApi='@("UseBlazore/List")'
   6:     param-Name="@("Name")"
   7:     type="typeof(TypedComponent.Pages.AutoComplete)"
   8:     render-mode="ServerPrerendered" />
   9:     <input type="submit" value="ok" class="btn btn-primary" />
  10:  </form>
  11:   @Model.Name<br />

 

Der Vollständigkeit halber der dazugehörige C# Code und die Deklaration des gebundenen Propertys Name. In der Praxis wird ein komplexes Objekt an eine Reihe von Input Feldern im Formular gebunden.

   1:  public class UseBlazoreModel : PageModel
   2:  {
   3:    [BindProperty]
   4:    public string Name { get; set; }

Nun wird es ernst und die Blazor Komponente finalisiert. Ist nicht ganz trivial, beginnen wir beim einfachen, den Infrastruktur Teil. HttpClient injizieren und Parameter deklarieren

   1:  @inject IHttpClientFactory Http
   2:  .....<html>
   3:   
   4:  [Parameter]
   5:  public String ServiceUrl { get; set; }
   6:  [Parameter]
   7:  public int MinLength { get; set; }
   8:  [Parameter]
   9:  public string LookupApi { get; set; }
  10:  [Parameter]
  11:  public string Name { get; set; }
  12:  [Parameter(CaptureUnmatchedValues = true)]
  13:  public Dictionary<string, object> InputAttributes { get; set; }
  14:  public string Eingabe { get; set; }

Der deklarative HTML Teil ist kurz aber mächtig. Wo fang ich an? Die Schachtelung der DIVs und CSS Klassen dient dazu den Piopup Teil unterhalb des Input Elements zu platzieren.

image

Ein der Stärken von Blazor ist Databinding per @bind und @bind-value, wie es ganz oben im ersten Prototypen verwendet wurde. Allerdings benötige ich @onchange bzw genauer @oninout (change bei jeder Eingabe) in Kombination mit @Bind. Das geht aber nicht, da Bind intern Onchange implementiert. Also musste in Zeile 2-3 das Binding sozusagen nachimplementiert werden.

Recht einfach ist das Anzeigen der Ergebnisliste mit einem foreach. Wenn die Liste null ist, wird nichts angezeigt. Klickt der Benutzer auf einen Eintrag muss der Wert in das Input Element übernommen und die Liste auf Null gesetzt um im Layout das Dropdown verschwinden zu lassen.

   1:   <div style="position: relative;display:inline-block;">
   2:      <input name="@Name" value="@Eingabe" 
   3:         @oninput="@((e) => {Eingabe = e.Value.ToString(); laden(Eingabe);})" 
   4:         @attributes="InputAttributes"
   5:         autocomplete="off"  >
   6:       @if (VorschlagListe != null)
   7:        {
   8:         <div class="dropdown-menu show" style="position: absolute; ">
   9:             @foreach (var item in VorschlagListe)
  10:                {
  11:                 <a class="dropdown-item "
  12:                     @onclick="(() => SetInput(item))"
  13:                     @onclick:preventDefault>@item</a>
  14:                 }
  15:          </div>
  16:         }
  17:  </div>

 

Man kann natürlich die Lambda Expression Funktionszuweisung in Zeile 3 auch zusammenfassen, so wie in SetInput.

   1:  void SetInput(string item)
   2:  {
   3:      Eingabe = item;
   4:      VorschlagListe = null;
   5:   }

Letztendlich noch die Initialisierung der Vorschlagsliste und das Füllen derselben per REST Call und System.Text Json Derserialisierung.

   1:  public List<string> VorschlagListe { get; set; }
   2:   
   3:  async void laden(string suche)
   4:   {
   5:   if (suche != null)
   6:     {
   7:     var client = Http.CreateClient();
   8:     var response = await client.GetAsync
($"https://localhost:44382/{LookupApi}?suche={suche}");
   9:     if (response.IsSuccessStatusCode)
  10:      {
  11:       using var responseStream = 
await response.Content.ReadAsStreamAsync();
  12:     VorschlagListe = await 
JsonSerializer.DeserializeAsync<List<String>>(responseStream);
  13:   
  14:       StateHasChanged();
  15:       }
  16:   }
  17:          }

 

Ich will ganz ehrlich sein. Aus Performance Sicht ist es ein graus vom Webserver eine Funktion innerhalb des selben Servers, der selben App nicht direkt aufzurufen. Ein Httprequest vom Server zum Server erscheint absurd. Ist aber noch immer um Ecken performanter als  vom Browser zum Server. Die Schönheit an der Lösungs ist, das man so die Logik auch in eine Blazor WebAssembly Lösung bringen könnte- theoretisch Da muss ich noch ein wenig drüber Nachdenken. Ebenso noch eine Flei0aufgabe die Architektur zu überdenken und am Ende doch noch ohne HTTPClient direkt eine In Memory Objekt Kommunikation hinzubekommen.

Achja fast vergessen. Natürlich kann man die Blazor Component auch einer Blazor Page verwenden. Fast genauso wie mit dem Taghelper nur viel eleganter.

   1:  <AutoComplete placeholder="halloo" class="form-control" 
   2:        MinLength="2"
   3:        LookupApi='UseBlazore/List'
   4:        Name="Name">
   5:  </AutoComplete>
Kommentare sind geschlossen