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

In einem früheren Beitrag hatte ich bereits gezeigt, dass ein regulärer AngularJS-Filter selten ausreicht, um eine Suche zu implementieren, weil sich die angezeigten Daten signifikant von den Daten im Applikations-Daten-Modell unterscheiden. Heute werde ich zeigen, wie ein Filter auf exakt das angewendet werden kann, was auch sichtbar ist.

Das Problem wird in der fiktiven Abonnentenliste aus dem vorherigen Beitrag deutlich. Eine Suche nach Italy funktioniert dort nicht, weil die Rohdaten lediglich die Zeichenkette IT und nicht Italy enthalten. Im früheren Beitrag wurde ebenfalls klar, dass sich das Problem nur durch einen eigenen Filter lösen lässt, der die gerenderten, sichtbaren Daten untersucht, und nicht die Rohdaten aus dem Model.

Alles kann auch lokal nachvollzogen werden:

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

Dies startet die finale (funktionierende) Version. Der Initialzustand lässt sich mit dem Tag wrapper-filter auschecken.

$ git checkout wrapper-filter

Der AngularJS-Filter wird visibleFilter heißen und ist in src/app/shared/visibleFilter.js implementiert:

'use strict';

angular.module('myApp')
.filter('visibleFilter', [
    '$filter',
function($filter) {
    return function (array, search) {
        return $filter('filter')(array, search);
    };
}]);

Dieser Filter ist einfach nur ein Wrapper um den Standard-AngularJS-Filter, der wenig originell filter heißt. Jeder AngularJS-Filter bekommt als erstes Argument das zu filternde Array. Weitere optionale Argumente können im HTML zugefügt werden:

<div class="row table-row"
     ng-class="{'odd': $index % 2 === 1, 'even': $index % 2 === 0}"
     ng-repeat="subscriber in subscribers | visibleFilter: query">
Die Variable `query` is an das Eingabefeld mit dem Suchbegriff gebunden. Das Verhalten der Applikation ist mit dem Wrapper exakt wie vorher. Extrahieren und Filtern des sichtbaren Inhalts ---------------------------------------------- Im ersten Schritt muss der sichtbare Content ermittelt werden und statt der Rohdaten gefiltert werden. Dafür muss der Filter wissen, wo dieser Content im DOM gespeichert ist. Deshalb erhält der Tabellen-Bodz ein id-Attribut `subscribers`, alle Tabellenzeilen bekommen eine CSS-Klasse `table-row`, und alle Tabellenzellen bekommen eine CSS-Klasse `table-cell`:
<div id="subscribers">
  <div class="row table-row" data-pkey="{{subscriber.id}}"
       ng-class="{'odd': $index % 2 === 1, 'even': $index % 2 === 0}"
       ng-repeat="subscriber in subscribers 
                  | visibleFilter:query:'subscribers':'id'">
    <div class="col-md-3 table-cell">
      {{ subscriber.givenName }} {{ subscriber.surname }}
    </div>
    <div class="col-md-3 table-cell">
      <span ng-show="subscriber.showEmail">{{ subscriber.email }}</span>
      <span ng-hide="subscriber.showEmail">-</span>
    </div>
    <div class="col-md-2 table-cell" table-cell>
      {{ subscriber.date | date }}
    </div>
    <div class="col-md-2 table-cell" table-cell>
      {{ subscriber.postings | number }}
    </div>
    <div class="col-md-2 table-cell" ng-show="showCountry">
      {{ subscriber.country | isoCountry }}
    </div>
  </div>
</div>

Es gibt noch weitere Änderungen: Jede Tabellenzeile wird zusätzlich mit einem Datenattribut data-pkey versehen, dass die Abonnenten-ID, den Primärschlüssel des Datenmodells, enthält. Weiterhin wird der Filter mit zwei weiteren Argumenten aufgerufen, subscribers ist die ID des HTML-Elements, das die Daten enthält. Und id ist der Name der Eigenschaft, dass den Primärschlüssel enthält. Ein Beispieldatensatz sieht so aus:

{
    "country" : "IT",
    "surname" : "Pirozzi",
    "id" : "jzhodg-6388-694720",
    "email" : "a.pirozzi@costa.it"
    "postings" : 3969,
    "givenName" : "Albano"
    "showEmail" : true,
    "date" : 1246609342000
}

Das Feld id enthält eine eindeutige Abonnenten-ID, die im Moment nicht angezeigt wird.

Die umfangreichste Änderung findet im Filter app/shared/visibleFilter.js statt:

'use strict';

angular.module('myApp')
.filter('visibleFilter', [
    '$filter',
function($filter) {
    return function (array, search, container, pkey) {
        var visible, subset, filtered, retval = [];

        if (array === undefined || search === undefined)
            return array;

        visible = extractTable(container);

        for (var i = 0; i < visible.length; ++i) {
            for (var j = 0; j < array.length; ++j) {
                if (array[j][pkey] === visible[i].pkey) {
                    array[j][':visible'] = true;
                    break;
                }
            }
            delete visible[i].pkey;
        }

        subset = array.filter(function(item) {
            return item[':visible'];
        });
        for (var i = 0; i < array.length; ++i) {
            delete array[i][':visible'];
        }

        filtered = $filter('filter')(visible, search);

        for (var i = 0, j = 0;
             i < visible.length && j < filtered.length;
             ++i) {
            if (visible[i] === filtered[j]) {
                retval.push(subset[i]);
                ++j;
            }
        }

        return retval;
    };

    function extractTable(container) {
        var elem = document.getElementById(container),
            rows, table = [];

        rows = elem.querySelectorAll('[data-pkey]');
        for (var i = 0; i < rows.length; ++i) {
            var cells = extractRow(rows[i]);

            cells.pkey = rows[i].dataset['pkey'];
            table.push(cells);
        }

        return table;
    }

    function extractRow(row) {
        var cells = row.querySelectorAll('.table-cell'),
             content = [];

        for (var i = 0; i < cells.length; ++i) {
            content.push(extractCell(cells[i]));
        }

        return content;
    }

    function extractCell(cell) {
        return cell.innerText || cell.textContent;
    }
}]);

In Zeile 4 sieht man die beiden neuen Argumente container für das ID-Attribut des HTML-Elements, das allen Content enthält, und id, den Feldnamen des Primärschlüssel des Daten-Modells.

Der frühe Ausstieg in den Zeilen 10 und 11 dient zwei Zwecken. Beim ersten Aufruf des Filter ist das Argument array noch undefiniert, und der Filter sollte deshalb unmittelbar zurückkehren. Und wenn der Suchbegriff noch nicht definiert ist, sollte das Eingabe-Array so wie es ist, zurückgeliefert werden. Aus Performancegründen sollte dies auch passieren, wenn der Suchstring leer ist, aber das wurde hier vergessen.

In Zeile 13 wird die Methode extractTable() aufgerufen, die die eigentliche Arbeit macht. Die Definition beginnt in Zeile 46 und sollte einigermaßen selbsterklärend sein. Die Funktion ermittelt alle Kindelemente mit dem Datenattribut data-pkey (alternativ ließe sich auch die CSS-Klasse table-row verwenden), iteriert über alle Zeilen und ruft schließlich eine weitere Methode extractRow(), die den Inhalt einer Zeile ermittelt. Diese ist in Zeile 61 definiert und arbeitet auf die gleiche Weise. Zuerst werden alle Kindelemente mit der Klasse table-cell ermittelt, und für jedes dieser Elemente wird die Methode extractCell aufgerufen, mit der textuelle Inhalt der Zelle rekursiv ermittelt wird.

Die resultierende Datenstruktur visible in Zeile 13 ist ein Array von Arrays, das den extrahierten Inhalt strukturiert in Zeilen und Zellen enthält.

Muss die Struktur tatsächlich so tief verschachtelt sein? Weshalb reicht es nicht, den Inhalt jeder Zeile auf einmal aus der DOM-Eigenschaft textContent auszulesen? Nehmen wir einen Abonnenten Karl Schlechtes mit der Mail-Adresse beispiel@example.com. Der aggregierte Inhalt der Zeile wäre also Karl Schlechtes beispiel@example.com. Eine Suche nach schlechtes Beispiel würde jetzt einen Treffer produzieren, weil die beiden Tabellenzellen zufällig nebeneinander liegen. Das kann man natürlich auch als Feature verstehen. In diesem Fall kann man es natürlich so machen. Am Konzept ändert das nichts.

Zeile 54 ist wichtig. Nach der Ermittlung des Zeileninhalts wird das Array mit einer zusätzlichen Eigenschaft pkey mit dem Inhalt des Attributs data-pkey versehen. Dieses Attribut stellt die Verbindung zwischen dem Datenmodell und den angezeigten Daten her, und erlaubt die eindeutige Identifizierung der sichtbaren Zeilen.

Zurück zu Zeile 13. Die Variable visible ist jetzt mit dem sichtbaren Tabelleninhalt gefüllt, und jede Zeile wurde mit dem Primärschlüssel des zugrundeliegenden Datenmodells versehen.

Dies bringt uns der Lösung einen Schritt weiter. Die sichtbaren Tabellenzeilen können jetzt durch Überprüfung des Primärschlüssels identifiziert werden. Dies passiert in der Schleife, die in Zeile 15 beginnt.

Die Variable array enthält die Rohdaten, mit denen AngularJS den Filter aufruft. Der nächste Schritt besteht darin, die Zeilen aus array zu filtern, die keiner sichtbaren Tabellenzeile entsprechen. Das ist einfach. Der Name des Primärschlüssels von array ist in der Variablen pkey hinterlegt, und der Primärschlüssel selber ist in der Eigenschaft pkey hinterlegt. Diese Eigenschaft wird jetzt aus dem Array visible in das Datenarray kopiert. Hierfür wird eine Eigenschaft :visible missbraucht, wobei wir uns darauf verlassen, dass dieser Name nicht anderweitig verwendet wird. In den Zeilen 25 bis 27 werden alle Zeilen in array mit dem Attribut :visible in ein neues Array subset gefiltert. Diese Variable enthält jetzt die Untermenge der Zeilen von array, denen eine sichtbare Zeile entspricht, also einer Zeile, die nicht als Resultat einer vorhergehenden Suche ausgeblendet wird.

Das Resultat dieser Operation ist, dass visible und subset dieselbe Anzahl an Zeilen haben, und diese Zeilen gleich geordnet sind. Der temporär in die Daten injizierte Primärschlüssel, wurde en passant (Zeile 22) wieder gelöscht, so dass er zu keinen falsch positiven Ergebnissen bei der Suche führen kann.

Das ursprüngliche Array wird in den Zeilen 28 bis 30 gesäubert. Dort wird das temporär eingefügte Feld :visible wieder entfernt. Das ist keine rein kosmetische Maßnahme, sondern wichtig, damit array für die nächste Suche wieder in den ursprünglichen Zustand versetzt wird.

Die eigentliche Filterung findet in Zeile 32 statt. Dort werden aber nicht die Rohdaten in array nach dem Suchbegriff gefiltert, sondern das Ergebnis der diversen Transformationen in visible. Diese gefilterten Daten werden in der Variablen filtered abgelegt. Dieses Array entspricht dem Array visible, jedoch ohne die Zeilen, die dem Suchbegriff nicht entsprechen.

Allerdings muss ein AngularJS-Filter eine Untermenge der Originaldaten zurückgeben. Das Ergebnis der Filterung muss also wieder in die ursprüngliche Struktur überführt werden.

Wie kann das erreicht werden? Dazu müssen wir zunächst einmal eine Bestandsaufnahme dessen machen, was wir haben. Um die Sache etwas zu vereinfachen, nehmen wir im Folgenden an, dass nur nach dem Land gesucht wird.

array visible subset filtered result
[ 'de' ]
[ 'GERMANY' ]
[ 'de' ]
[ 'GERMANY' ]
[ 'de' ]
[ 'es' ]
[ 'SPAIN' ]
[ 'es' ]
[ 'FRANCE' ]
[ 'fr' ]
[ 'fr' ]
[ 'FRANCE' ]
[ 'fr' ]
[ 'it' ]
[ 'ITALY' ]
[ 'it' ]
[ 'se' ]
[ 'uk' ]

Hier war der bisherige Suchbegriff a, und die Benutzerin hat ein weiteres n eingetippt, sucht also jetzt nach an.

Die Spalte array enthält die vollständigen, rohen Daten. Eigentlich werden sie in beliebiger Reihenfolge übergeben.

Die Spalte visible enthält die verarbeiteten Daten. Erster Unterschied ist, dass nur Spalten mit a auftauchen. Die Zeilen für UK und Sweden fehlen beispielsweise. Der zweite Unterschied besteht darin, dass die Daten vom View modifiziert wurden. Hier wurde zum Beispiel das Länderkürzel in einen (englischen) Ländernamen transformiert.

Die Spalte subset enthält also jetzt die Untermenge von array, die den sichtbaren Zeilen entspricht. Sie wurde aus array und visible durch Vergleich des Primärschlüssels gewonnen.

Die Variable filtered enthält die Untermenge von visible, die auf den neuen Suchbegriff an passt. Nur Germany und France erfüllen diese Voraussetzung.

Die Spalte result schließlich zeigt, wo wir hin wollen. Es müssen Zeilen im Format der Rohdaten zurückgegeben werden (hier Länderkürzel und nicht Ländernamen), und diese Zeilen müssen denen in filtered entsprechen.

result steht in der gleichen Beziehung zu filtered wie subset zu visible. Um die Beziehung zwischen subset und visible herzustellen, wurde der Primärschlüssel verwendet. Dieser musste aber gelöscht werden, um keine falsch positiven Daten zu produzieren.

Es geht aber auch ohne die Primärschlüssel. Die Elemente von filtered sind die selben wie die entsprechenden Elemente in visible, es sind die selben Objekte, sie zeigen auf dieselbe Sache. Und visible und subset haben die gleiche Anzahl von Elementen, sind gleich sortiert, und repräsentieren die gleichen Daten. Das heißt, immer wenn eine Zeile aus visible als Treffer für den neuen Suchbegriff identifiziert wurde, wird die Zeile aus subset, die den gleichen Index hat, der Ergebnismenge zugefügt.

Mit diesem Wissen lässt sich result aus filtered gewinnen. Das erste Element in filtered ist (das Javascript-Objekt) ['GERMANY']. Dieses Objekt liegt in visible an Position 0. Dieser Index 0 wird jetzt als Index in subset verwendet, wo wir das erste Element von result, nämlich ['de'] finden.

Der nächste Treffer ist ['FRANCE'], was wir an Position 2 in visible finden. Dementsprechend ist subset[2], also ['fr'] das zweite Element von result.

Die tätsächliche Implementierung, die im Code-Beispiel weiter oben in Zeile 34 beginnt, geht etwas effizienter vor und nutzt die Tatsache aus, dass filtered eine Untermenge von visible in der gleichen Reihenfolge ist. So wird mit der Variablen i über visible und mit j über filtered iteriert. i wird bei jedem Schleifendurchlauf inkrementiert, j nur im Falle einer Übereinstimmung, wobei dann auch das entsprechende Element subset[i] in die Ergebnismenge kopiert wird.

Die folgende Animation mag das etwas anschaulicher zeigen:

visible filtered

i = 0 A C j = 0

i = 1 B E j = 1

i = 2 C G j = 2

i = 3 D H j = 3

i = 4 E

i = 5 F

i = 6 G

i = 7 H

Der Stand nach diesem Schritt hat übrigens das Tag filter-transformed:

$ git checkout filter-transformed

Zurücksetzen des Filters bei Ändern der Suchabfrage

Die Applikation ist bereits verbessert. So liefert eine Suche nach Italy jetzt wirklich nur noch Zeilen für Abonnentinnen aus Italien. Es gibt allerdings keinen Weg zurück mehr. Löscht man vom Ende ausgehend nach und nach Zeichen, sucht also nacheinander nach Ital, Ital, Ita, It, I und schließlich nach nichts mehr, passiert dennoch absolut gar nichts. Es werden weiterhin nur die Abonnenten aus Italien angezeigt, und die einzige Möglichkeit dies zu ändern ist das Neuladen der Seite.

Der Grund dafür wird schnell klar. Die Suche erfolgt immer nur innerhalb des gerade sichtbaren Inhalts. Durch eine Veränderung der Suche kann sich die Ergebnismenge zwar verkleinern, aber niemals vergrößern. Einmal verschwundener Content kann nicht wieder zurückgeholt werden.

Wie lässt sich das beheben? Eine Lösung bestünde darin, den sichtbaren Content überhaupt nur einmal zu ermitteln und danach immer nur innerhalb dieser gespeicherten Menge zu suchen. Das würde zwar funktionieren, wäre aber etwas hässlich und würde dann nicht mehr funktionieren, wenn sich der Inhalt der Liste dynamisch änderte.

Eine bessere Lösung sieht so aus, dass der Filter bei (fast) jeder Änderung des Suchbegriffs zurückgesetzt wird, und AngularJS gezwungen wird, den vollständigen, ungefilterten Inhalt der Tabelle für einen Augenblick erneut anzuzeigen. Effektiv wird der Filter also immer zweimal aufgerufen. Beim ersten Mal wird das Array ungefiltert zurückgegeben, um den sichtbaren Inhalt ermitteln zu können. Beim zweiten Mal werden die so gewonnenen Daten gefiltert und in die Struktur des Datenmodells zurückgerechnet. Das ist längst nicht so ineffizient wie es sich anhört. Aufgrund der Art und Weise wie AngularJS die bidirektionale Datenbindung realisiert, wird der Filter ohnehin immer zweimal aufgerufen, und wir verlieren mit unserer Methode also nicht viel.

Eine mögliche Implementierung sieht so aus:

'use strict';

angular.module('myApp')
.filter('visibleFilter', [
    '$filter',
function($filter) {
    var lastSearches = {};

    return function (array, search, container, pkey) {
        var visible, subset, filtered, retval = [];

        if (array === undefined || search === undefined)
            return array;

        if (container in lastSearches
            && lastSearches[container] !== search
            && search.substr(0, lastSearches[container].length)
                !== lastSearches[container]) {
            lastSearches[container] = search;
            return array;
        }

        lastSearches[container] = search;

        visible = extractTable(container);
        
        ...
}

Was hat sich geändert? In Zeile 7 wird eine neue Hashvariable lastSearches gespeichert, in der für jede Tabelle getrennt -- es könnte ja mehrere Tabellen auf der Seite geben -- der letzte Suchausdruck gespeichert wird.

Die Rücksetzlogikg ist in den Zeilen 15 bis 21 implementiert. Dort wird zunächst geprüft, ob der letzte Suchausdruck modifiziert wurde. Die Zeilen 17 und 18 enthalten eine kleine Optimierung. Der häufigste Fall ist ja der, dass einfach Zeichen an das Ende des Suchbegriffs angehangen werden, was beim Eintippen des Suchausdrucks passiert, wo beispielsweise nacheinander nach I, It, Ita, Ital, und schließlich Italy gesucht wird. In diesem besonderen Fall ist es in Ordnung, lediglich in der letzten Ergebnismenge zu suchen, weil die Ergebnismenge sich bei weiterer Verfeinerung der Suche unmöglich vergrößern kann. Das wird in den Zeilen 17 und 18 ausgenutzt, wo das Zurücksetzen nicht vorgenommen wird, wenn der letzte Suchbegriff ein initialer Substring des neuen Suchausdrucks ist.

Diese Optimierung darf nicht vorgenommen werden, wenn sich der Inhalt der Liste dynamisch ändern kann, weil die Annahme dann nicht mehr korrekt wäre. In diesem Falle müsste aber ohnehin eine Re-Evaluierung des Filters stattfinden.

Auf der anderen Seite könnte man auch noch etwas weiter gehen. Groß- und Kleinschreibung könnte beim Substringvergleich ignoriert werden, und der Substring muss nicht zwangsläufig am Anfang des letzten Suchausdrucks verankert sein. Der potenzielle Gewinn wäre allerdings vernachlässigbar, weil diese Verbesserung praktisch nie zum Tragen käme, und außerdem hat JavaScript keine Standardfunktion strcasestr(). Der entsprechende Code müsste also erst geschrieben werden.

Ergebnis ist jetzt jedenfalls, dass der Filter nach jeder Änderung des Suchbegriffs kurzzeitig auf den leeren String zurückgesetzt wird, es sei denn, das lediglich Zeichen angehangen wurden. Daraufhin wird der sichtbare Content erneut ermittelt, und der Filter ein zweites Mal angewandt. Das läuft so schnell ab, dass es für die Benutzerin nicht sichtbar ist.

Der Zustand der Applikation nach diesem Schritt hat im Git das Tag editable-query.

Ausgeblendete Inhalte ignorieren

Die Applikation hat ja noch diese wenig nützliche Checkbox, mit der sich die Länderspalte ausblenden lässt. Lädt man die Applikation im Browser neu und wählt die Checkbox an, verschwinden die Ländernamen. Bei einer erneuten Suche nach Italy tauchen aber weiterhin nur Abonnenten aus Italien auf, obwohl die Zeichenkette Italy überhaupt nicht auf der Seite vorkommt.

Ursache ist die Art und Weise, wie der Inhalt aus dem HTML gewonnen wird. Sehen wir uns die Funktionen extractRow und extractCell erneut an:

function extractRow(row) {
        var cells = row.querySelectorAll('.table-cell'),
             content = [];

        for (var i = 0; i < cells.length; ++i) {
            content.push(extractCell(cells[i]));
        }

        return content;
    }

    function extractCell(cell) {
        return cell.innerText || cell.textContent;
    }
Die DOM-Eigenschaft `textContent` beachtet lediglich Markup, ignoriert aber CSS-Regeln. Das muss geändert werden. Es gibt natürlich viele Möglichkeiten, um Inhalte mit CSS zu verstecken. Wir behandeln hier nur den gängigsten Fall, dass nämlich das CSS-Attribut `display` auf `none` gesetzt ist. Dies ist nämlich auch die Methode, die AngularJS für die Implementierung von `ng-show` und `ng-hide` verwendet. Die beiden oben erwähnten Funktionen müssen dafür wie folgt geändert werden:
function extractRow(row) {
        var cells = row.querySelectorAll('.table-cell'),
            content = [];

        for (var i = 0; i < cells.length; ++i) {
            if (cells[i].offsetParent !== null)
                content.push(extractCell(cells[i]));
        }

        return content;
    }

    function extractCell(cell) {
        var children = cell.childNodes, content = '';
        for (var i = 0; i < children.length; ++i) {
            switch(children[i].nodeType) {
            case 1:
                if (children[i].offsetParent !== null)
                    content += extractCell(children[i]);
                break;
            case 3:
                content += children[i].innerText || children[i].textContent;
                break;
            }
        }
        return cell.innerText || cell.textContent;
    }
Die Funktion `extractCell()` wurde so geändert, dass der Zelleninhalt jetzt rekursive ermittelt wird. Dazu wird über alle Kindknoten des Elements iteriert (Zeile 14). Ist der Kindknoten eine Elementknoten (Typ 1), und ist er sichtbar (Zeile 18), wird die Funktion rekursiv mit diesem Kindelement aufgerufen. Ist das Kind jedoch ein Textknoten (Typ 3), wird der entsprechende Text angehangen. Zusätzlich muss in Zeile 6 noch geprüft werden, ob die Zelle überhaupt sichtbar ist, bevor `cellContent()` initial aufgerufen wird. Das Attribut `offsetParent` ist übrigens `null`, falls das Attribut `display` des Elements den Wert "none" hat. Nach dieser Änderung wird nur noch der Content für die Suche in Betracht gezogen, der wirklich im Browser sichtbar ist. Der aktuelle Zustand ist im Git mit "ignore-hidden" getaggt. Anzeige der Anzahl der Treffer ------------------------------ Es gibt noch ein weiteres Problem: Oberhalb der Tabelle wird die Anzahl der Zeilen angezeigt, und diese Anzeige funktioniert noch nicht. Ein Blick auf den HTML-Code fördert den Grund schnell zutage:
<em>
  Displaying {{ (subscribers | filter: query).length }}
  of {{ subscribers.length }} entries.
</em>

Hier wird noch immer der Standardfilter verwendet, um die Anzahl der Treffer zu ermitteln. Das könnte man einfach mit einem Aufruf des neuen Filters ersetzen, was aber nicht sehr effizient wäre. Die Anzahl der Treffer ist ja während der Berechnung der Treffermenge bekannt und muss nur einfach exportiert werden:

'use strict';

angular.module('myApp')
.filter('visibleFilter', [
    '$filter',
    '$rootScope',
function($filter, $rootScope) {
    var lastSearches = {};

    $rootScope.matches = {};

    return function (array, search, container, pkey) {
        var visible, subset, filtered, retval = [];

        if (array === undefined)
            return array;

        if (container !== undefined)
            $rootScope.matches[container] = array.length;

        if (search === undefined)
            return array;

        ...
        
        $rootScope.matches[container] = retval.length;

        return retval;
    };
Die Anzahl wird wieder pro Tabelle in der Root-Scope-Variablen `matches` hinterlegt. Dafür muss `$rootScope` als zusätzliche Abhängigkeit injiziert werden (Zeilen 6 und 7). In Zeile 19 wird die Trefferzahl zunächst mit der Größe des Ausgangs-Arrays initialisiert, damit dieser Code nicht für jedes der folgenden Return-Statements wiederholt werden muss. Wird die Ergebnismenge ermittelt, wird die Anzahl der Treffer vor der Rückgabe der Treffermenge in Zeile 28 gespeichert. Das HTML muss entsprechend geändert werden:
<em>
  Displaying {{ matches['subscribers'] }}
  of {{ subscribers.length }} entries.
</em>

Dieser Zustand kann mit der Spitze des Branches visible ausgecheckt werden:

$ git checkout visible

Man kann die Applikation auch in Aktion sehen.

Weitere Verbesserungen

Die vorgestellte Lösung ist noch immer nicht perfekt und muss sicher noch and die individuellen Bedürfnisse des eigenen Projekts angepasst werden, und es gibt natürlich auch noch Luft für Verbesserungen.

Die Anzahl der Beiträge ist mit dem Standardfilter number von AngularJS formatiert, so dass 1234 als 1,234 mit Komma als Tausendertrenner dargestellt wird. Es wäre vermutlich besser, wenn 1,234 und 1234 das gleiche Ergebnis liefern würden. Das könnte mit einer kleinen Änderung im HTML relativ einfach bewerkstelligt werden:

<div class="col-md-2 table-cell" data-filter-alt="{{ subscriber.postings }}">
    {{ subscriber.postings | number }}
</div>

Die unformatierte Zahl kann im Datenattribut filter-alt gespeichert werden. Die Funktion extractCell() müsste dann entsprechend angepasst werden, damit auch der Inhalt des Datenattributs dem zu durchsuchenden Content zugefügt werden.

Manchmal wäre es wünschenswert, den zu durchsuchenden Content nicht zu ergänzen, sondern zu ersetzen:

<div class="col-md-3 table-cell" 
     data-filter-content="{{ subscriber.givenName }} {{ subscriber.surname }}">
    {{ subscriber.givenName }}&nbsp;{{ subscriber.surname }}
</div>

Würde beispielsweise Vor- und Nachname durch ein Non-Breaking-Space getrennt dargestellt, würde ein Suche nach VORNAME NACHNAME nicht mehr funktionieren, weil niemand Non-Breaking-Spaces in Suchfelder eingibt.

Dies könnte auf zwei Arten gelöst werden. Man könnte in der Funktion extractCell() alle Non-Breaking-Spaces (oder sogar alle Folgen von Whitespace) durch ein einzelnes Leerzeichen ersetzen. Eine flexiblere Lösung ist oben angedeutet, wo dem Datenattribut filter-content Priorität vor dem eigentlichen Inhalt der Datenzelle gegeben würde.

Ein weiterer Spezialfall kann auftreten, wenn der Tabelleninhalt editierbar ist. Beim Editieren in einer gefilterten Ergebnismenge, ist es eine gute Idee, den ursprünglichen Inhalt der Tabelle in versteckte Felder zu kopieren, damit die gerade bearbeitete Zeile nicht plötzlich verschwindet, weil sie nicht mehr auf den Suchausdruck passt. In diesem Fall sollte versteckter Content natürlich nicht ignoriert werden.

Solche oder ähnliche Anpassungen werden in fast jeder Applikation notwendig sein.


blog comments powered by Disqus