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.
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:
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
:
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:
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
:
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