Filtern und Suchen in sichtbarem Content mit AngularJS (Teil 1/2)

Mit Filtern lassen sich in AngularJS Volltextsuchen über kleine Datenbestände mit minimalem Aufwand realisieren. Das suggeriert jedenfalls die Dokumentation von AngularJS. Bei ernsthaften Anwendungen führt die naive Anwendung eines Angular-Filters auf das Datenmodell jedoch regelmäßig zu aus Usersicht mehr oder weniger willkürlichem Verhalten.

Schauen wir uns zunächst eine Anwendung an, bei der es klappt, den Handykatalog von AngularJS. Wird hier zum Beispiel Moto ins Suchfeld eingegeben, reduziert das die Liste auf Einträge mit Geräten von Motorola.

Das Coole daran ist, dass der Mechanismus fast vollständig in HTML implementiert wird:

<div class="container-fluid">
  <div class="row">
    <div class="col-md-2">
      <!--Sidebar content-->

      Search: <input ng-model="query">

    </div>
    <div class="col-md-10">
      <!--Body content-->

      <ul class="phones">
        <li ng-repeat="phone in phones | filter:query">
          {{phone.name}}
          <p>{{phone.snippet}}</p>
        </li>
      </ul>

    </div>
  </div>
</div>

In Zeile 6 wird der Inhalt des Suchfeldes an die Variable query gebunden. Und in Zeile 13 wird der Repeater mit dem Inhalt der Variablen gefiltert.

Ich habe eine auf den ersten Blick sehr ähnliche Anwendung vorbereitet, eine Abonnentenliste eines fiktiven Forums.

Abonnentenliste

Die Liste enthält für alle Abonnenten den Vor- und Nachnamen, die E-Mail-Adresse (oder einen Bindestrich - falls der Veröffentlichung widersprochen wurde), das Datum, wann dem Forum beigetreten wurde, die Anzahl der Beiträge und das Herkunftsland. Das Land lässt sich ausblenden, wenn die entsprechende Checkbox abgewählt wurde.

Wir wollen jetzt alle Abonnentinnen und Abonnenten des Forums ermitteln, die aus Italien kommen:

Liste nach "I" gefiltert.

Nachdem das I in das Suchfeld eingegeben wurde, hat sich der sichtbare Teil Liste zunächst einmal nicht geändert. Den Effekt des Fiters sehen wir dennoch, denn neben dem Suchfeld lesen wir jetzt, dass nur noch 172 von 198 Einträgen angezeigt werden. Dass soviele Zeilen ein I enthalten ist nicht ungewöhnlich, und deshalb stellt dies keine Überraschung dar.

Schauen wir jedoch genau hin, bemerken wir einen Eintrag für den Abonnenten Frank Gardner. In seinem Eintrag kommt kein I vor, weder groß- noch kleingeschrieben.

Wir geben den nächsten Buchstaben t ein, suchen jetzt also nach It:

Liste nach "It" gefiltert.

Es werden jetzt nur noch 37 von 198 Einträgen angezeigt. Aber Sportsfreund Frank Gardner wird immer noch fälschlicherweise aufgelistet.

Suchen wir jetzt weiter nach Ita ist die Liste plötzlich auf einen Eintrag geschrumpft:

Liste nach "ita" gefiltert hat nur noch einen Eintrag.

Der i-lose Frank Gardner ist verschwunden. Übrig bleibt nur noch Saro Napolitano aus Italien. All seine Landsleute aber sind verschwunden. Tippen wir noch einen Buchstaben mehr ein, sehen wir die Auswahl für Ital:

Liste nach "ital" gefiltert ist leer.

Die Liste ist leer, obwohl wir erwarten würden, jetzt mindestens alle Einträge mit ITALY zu finden. Wir haben also einen groben Fehler in unserer Logik. Um das Phänonmen zu verstehen, müssen wir den Quelltext der Applikation näher untersuchen.

Anatomie der Beispiel-Applikation

Wer will, kann das Beispiel und auch die folgenden Schritte lokal selber nachvollziehen:

$ git clone -b broken git://git.guido-flohr.net/web/angular/angular-filter-visible-content.git
$ cd angular-filter-visible-content
$ npm start

Vorausgesetzt, git und npm sind installiert, sollte das Beispiel jetzt unter http://localhost:8000/app/ verfügbar sein.

Die eigentliche Applikation in app/app.js ist denkbar simpel:

'use strict';

angular.module('myApp', []);

Danach folgt die fiktive Abonnentendatenbank:

angular.module.constant('SUBSCRIBERS', {
   {
      "country" : "IT",
      "surname" : "Pirozzi",
      "id" : "jzhodg-6388-694720",
      "email" : "a.pirozzi@costa.it",
      "postings" : 3969,
      "givenName" : "Albano",
      "showEmail" : true,
      "date" : 1246609342000
   },
   {
      "surname" : "Jankowski",
      "email" : "i.jankowski@zalewski.pl",
      "id" : "egkyan-6955-444173",
      "country" : "PL",
      "givenName" : "Iwan",
      "postings" : 4482,
      "showEmail" : true,
      "date" : 1246743355000
   },
   // ...
});

Jetzt wird das Problem beim Suchen bzw. Filtern direkt klar. In den Rohdaten taucht der volle Ländername (z. B. ITALY) gar nicht auf, sondern lediglich der ISO-3166-1-Code für das Land. Der Suchstring It matcht auf alle Einträge mit II, aber Ita beispielsweise nicht mehr. Der einzige Treffer war dem Zufall geschuldet, weil Saro Napol**ita**no den Suchstring Ita im Nachnamen enthielt.

Der Eintrag für Frank Gardner sieht so aus:

{
      "date" : 1319694218000,
      "showEmail" : false,
      "givenName" : "Frank",
      "postings" : 4684,
      "id" : "vfydee-7981-686357",
      "email" : "f.gardner@nesbitt.net",
      "surname" : "Gardner",
      "country" : "UK"
   },
Jetzt wird ein weiteres Problem deutlich: Frank Gardners Eintrag matchte auf "It", weil die Mailadresse "f.gardner@nesb**it**.net" den Suchstring enthielt. Das Flag "showEmail" für Frank Gardner ist zwar auf `false` gesetzt, und die Adresse wird deshalb nicht angezeigt, aber sie wird dennoch mit durchsucht und kann auch Treffer liefern. Es gibt auch noch ein weiteres Feld "id" mit einer kryptischen Abonnenten-ID. Dieses Feld wird überhaupt nicht ausgewertet, und für die Applikation auch nicht benötigt, aber ist ebenfalls durchsuchbar, was zu nicht nachvollziehbaren Ergebnissen führen kann. Bevor wir Lösungsmöglichkeiten erörtern, sollten wir uns noch ein vollständiges Bild des Quelltextes machen. Der Controller `app/components/subscribersController.js` sortiert den Datenhash in die $scope-Variable `subscribers`:
'use strict';

angular.module('myApp')
.controller('subscribersController', [
    '$scope',
    'SUBSCRIBERS',
function($scope, SUBSCRIBERS) {
    $scope.subscribers = SUBSCRIBERS.sort(function(a, b) {
        if (a.postings < b.postings)
            return +1;
        if (a.postings > b.postings)
            return -1;
        return 0;
    });
    $scope.showCountry = true;
}]);

In Zeile 8 bis 14 werden die Datensätze nach der Anzahl der Postings sortiert. Das Flag showCountry triggert die Sichtbarkeit der Spalte mit den Herkunftsländern (zugegeben, ein Feature von zweifelhaftem Nutzen).

Der relevante Teil des Views app/index.html ist Standard für AngularJS:

<div class="row">
  <div class="col-md-8">
    <label for="query">
      Search:
    </label>
    <input name="query" ng-model="query" id="query"
           placeholder="Enter query!" class="search" autofocus>
  </div>
</div>
  <div class="col-md-7">
    <em>
      Displaying {{ (subscribers | filter: query).length }}
      of {{ subscribers.length }} entries.
    </em>
  </div>
  <div class="col-md-2">
    <input type="checkbox" ng-model="showCountry" id="show-country"
           ng-true-value="true" ng-false-value="false">
    <label for="show-country">
      Show country
    </label>
  </div>
<!-- Überschriften aus Darstellungsgründen weggelassen. -->
<div class="row table-row"
     ng-class="{'odd': $index % 2 === 1, 'even': $index % 2 === 0}"
     ng-repeat="subscriber in subscribers | filter: query">
  <div class="col-md-3">
    {{ subscriber.givenName }} {{ subscriber.surname }}
  </div>
  <div class="col-md-3">
    <span ng-show="subscriber.showEmail">{{ subscriber.email }}</span>
    <span ng-hide="subscriber.showEmail">-</span>
  </div>
  <div class="col-md-2">
    {{ subscriber.date | date }}
  </div>
  <div class="col-md-2">
    {{ subscriber.postings | number }}
  </div>
  <div class="col-md-2" ng-show="showCountry">
    {{ subscriber.country | isoCountry }}
  </div>
</div>

Die Repeater ist in Zeile 26 definiert. In der Loop-Variablen subscriber werden die einzelnen Datensätze abgelegt. Der aktuelle Filterwert (Suchbegriff) ist in der Variablen query hinterlegt, die in Zeile 6 an den Wert des Eingabefeldes für den Suchbegriff gebunden wurde.

Der Name (Zeile 28) und die E-Mail-Adresse (Zeile 31) werden durchgereicht, alle anderen Felder werden durch Angular-Direktiven gefiltert. Das Anmeldedatum liegt in Millisekunden seit der Epoche (1. Januar 1970, 00:00 UTC) vor und wird in Zeile 35 mit der Direktive date in eine lesbare Form transformiert.

Der Filter number (Zeile 38) gehört ebenfalls zu AngularJS und formatiert Zahlen, hier ins US-amerikanische Format mit Komma als Tausender-Separator.

In Zeile 49 schließlich verwenden wir den Filter isoCountry aus dem Paket iso-3166-country-codes-angular, um aus den Ländercodes die englischsprachigen Länderbezeichnungen zu generieren.

Analyse des Problems

Drei Fälle sind für das Problem zu unterscheiden.

Es ist möglich, dass eine Spalte in der Tabelle exakt eine Spalte des Datenmodells anzeigt. Das im Beispiel bei der Mailadresse der Fall. Wirklich? Nein, nur fast. Teilweise wird die Mailadresse nicht angezeigt.

Auch die Spalte mit dem Namen passt nicht ganz auf den Fall. Denn tatsächlich enthält sie den Vor- und Nachnamen durch ein Leerzeichen getrennt. Sowohl eine Suche nach Saro als auch nach Napolitano liefert Saro Napolitano als Treffer. Wird hingegen nach dem vollen Namen, also nach Saro Napolitano gesucht, gibt es keine Treffer, weil Vor- und Nachname im Datenmodell in getrennten Feldern abgelegt sind.

Dann gibt es den Fall, dass der angezeigte Wert im Datenmodell nicht existiert bzw. durch eine Transformation erzeugt wird. Das ist beispielsweise bei der Spalte für das Land oder das Beitrittsdatum der Fall.

Und schließlich gibt es noch Felder im Datenmodell, die bei der Ausgabe ignoriert werden, beispielsweise die Abonnenten-ID. Diese können zu False Positives führen, weil sie beim Filtern mit ausgewertet werden.

Lösungsansätze

Man könnte die Daten bereits im Controller bereinigen und vollständig verarbeitet an die View-Komponenten übergeben. Faktisch bedeutet das, dass grundsätzliche keine Angular-Direktiven innerhalb von Angular-Repeatern verwendet werden dürfen. Das wiederum widerspricht dem MVC-Paradigma, weil View und Controller nicht mehr sauber getrennt werden können. Beispielsweise müssten im Controller Zahlen formatiert oder Strings übersetzt werden, beides Dinge, die gemeinhin als Indizien für schlechtes Design gelten.

Weiterhin muss diese Arbeit für jeden einzelnen Fall geleistet werden. Es lässt sich nicht verallgemeinern. Spätestens dann, wenn die Oberfläche auf Benutzerinteraktion reagiert, versagt der Ansatz völlig. In unserem Beispiel kann man die Spalte Country für das Herkunftsland ein- und ausblenden. Um dies abzubilden, müsste man einen On-Click-Handler schreiben, der die Daten der Spalte dann jeweils aus dem Model löscht oder sie zurückkopiert.

Das kann es also nicht sein.

Lässt sich das Problem mit einem anderen Aufruf des Filters lösen? Die Syntax sieht so aus:

{{ filter_expression | filter : expression : comparator }}

Für expression kann nicht nur ein einfacher String verwendet werden, sondern auch ein JavasScript-Objekt oder eine Funktion.

Übergibt man ein Objekt, lassen sich komplexe Regeln realisieren, indem Bedingungen für mehrere Properties auf einmal spezifiziert werden. Das hilft hier nicht weiter.

Mit einer Funktion lassen sich laut Angular-Dokumentation beliebige Filter realisieren. Sie bekommt jeden einzelnen Datensatz übergeben, und kann dann über den Rückgabewert true oder false bestimmen, ob der Datensatz in der Ergebnismenge enthalten sein soll oder nicht. Das sieht auf den ersten Blick so aus, als wäre es die Lösung des Problems, aber:

  • Die Funktion muss genau wissen, was im View passiert, welche Transformationen angewandt werden.

  • Die Funktion muss für jeden Anwendungsfall gesondert geschrieben werden, sie lässt sich nicht verallgemeinern.

Am Ende des Tages wird View-Code im Controller wiederholt. Das ist nicht nur hässlich, sondern hilft auch nicht wirklich, weil von der Funktionalität von ngFilter praktisch nichts übrig bleibt.

Letztes optionales Argument für Angular-Filter ist comparator, mit dem eine eigene Vergleichsfunktion angeben werden kann. Auch das hilft nicht weiter. Hier könnte eine Funktion geschrieben werden, die statt der Rohdaten nur das Ergebnis der verschiedenen Transformationen in Betracht zieht. Das führt zu den gleichen Problemen: Die Funktion muss genau an den View-Code angepasst sein, wir weichen die Trennung zwischen View und Controller auf und bauen eine hässliche, schwer wartbare und fehleranfällige Lösung.

Hinweis: Wer trotzdem eine der oben beschriebenen hässlichen Lösungen präferiert, sei gewarnt, dass hier viele Detailprobleme warten. Die Lösungen sind nicht nur hässlich sondern auch kompliziert!

Benutzererwartung

Versetzen wir uns in den Benutzer des Filters hinein. Der weiß weder etwas von möglichen Hidden-Input-Feldern, noch von verstecktem Content, der nur unter bestimmten Umständen angezeigt wird, noch weiß er etwas von der Existenz von Hilfsvariablen oder sonstigem Müll, der in den Daten enthalten ist.

Er will einfach nur den sichtbaren Content durchsuchen oder filtern. Und genau das muss eine Lösung bieten. Nicht die Rohdaten dürfen zum Suchen oder Filtern verwendet werden, sondern genau das, was der jeweilige Benutzer in der jeweiligen Situation tatsächlich sieht.

Fazit

Filter in AngularJS sind leider weit weniger nützlich als sie auf den ersten Blick erscheinen. Für Telefonverzeichnisse und CD-Datenbanken aus Tutorials für relationale Datenmodelle funktionieren sie sehr gut. Sobald die Daten aus realen Quellen kommen, werden wir mit Schwierigkeiten konfrontiert, weil Filter auf die Gesamtheit der Rohdaten und nicht auf die aus den Rohdaten gewonnen Anzeigedaten wirken.

Im nächsten Teil werden wir eine Lösung entwickeln, mit der das Problem auf generische Weise gelöst werden kann.


blog comments powered by Disqus