major
#29531
Filterleiste für Tabellen: Presets, Freitextsuche über die sichtbaren Spalten und benutzereigene gespeicherte Filter
Tabellen der React-Oberfläche (das <table>-Element der .view.xml-Schicht und alle weiteren Tabellen, die über TableViewControl dargestellt werden) lassen sich bisher ausschließlich pro Spalte filtern: Über das Filtersymbol im Spaltenkopf öffnet sich ein Dialog, in dem die Kriterien genau dieser einen Spalte eingestellt werden. Es gibt keine Möglichkeit, eine Tabelle mit einem Suchbegriff spaltenübergreifend einzuschränken, häufig benötigte Filterkombinationen mit einem Klick zu aktivieren oder eine selbst eingestellte Filterkombination für die spätere Wiederverwendung zu benennen.
Neues Verhalten
Über dem Spaltenkopf erscheint eine Filterleiste, sobald sie an der Tabelle eingeschaltet ist (filter-bar="true"). Sie enthält drei Bestandteile:
- Presets als Chips
- Benannte Filterkombinationen werden als anklickbare Chips dargestellt. Ein Klick aktiviert die Kombination, d. h. die zugehörigen Spaltenfilter werden tatsächlich gesetzt - die Filtersymbole der betroffenen Spalten zeigen den aktiven Filter an, und der Anwender kann die Kriterien anschließend wie gewohnt weiter verfeinern. Ein Preset ersetzt die gesamte Filterkombination; ein zweiter Klick auf den aktiven Chip hebt alle Filter wieder auf. Der Chip der momentan aktiven Kombination ist hervorgehoben. Diese Hervorhebung wird abgeleitet: Sie zeigt den (ersten) Chip, dessen Kriterien den aktuell eingestellten Filtern und dem aktuellen Suchbegriff entsprechen. Verändert der Anwender einen Spaltenfilter, ist damit automatisch kein Chip mehr aktiv - die Anzeige kann nicht "lügen".
- Freitextsuche
- Ein Suchfeld schränkt die Tabelle spaltenübergreifend ein: Angezeigt wird eine Zeile, wenn der Suchbegriff in mindestens einer der sichtbaren Spalten vorkommt (die Suche prüft den dargestellten Text der Zelle). Die Suche wirkt zusätzlich zu den Spaltenfiltern (UND-Verknüpfung) und wird während der Eingabe wirksam (entprellt; Eingabetaste sucht sofort). Spalten, die der Anwender ausgeblendet hat, werden nicht durchsucht.
- Gespeicherte Filter je Benutzer
- Der Anwender kann die aktuell eingestellte Filterkombination (Spaltenfilter und Suchbegriff) unter einem eigenen Namen speichern und wieder löschen. Diese gespeicherten Filter erscheinen als Chips neben den konfigurierten Presets und stehen dem Anwender in späteren Sitzungen wieder zur Verfügung. Sie sind privat, also nur für den jeweiligen Benutzer sichtbar.
Presets können vom Anwendungsentwickler in der Ansicht konfiguriert werden, so dass eine Tabelle mit sinnvollen, benannten Filtern ausgeliefert werden kann. Ein Kriterium benennt eine Spalte und beschreibt, was in ihr ausgewählt wird - entweder als Kurzform expr (ein TL-Script-Ausdruck, dessen Wert der Filter der Spalte selbst in seinen Zustand übersetzt: Text -> Teilstring, Auswahlfilter -> gewählte Werte, Wahrheitswert, zweielementige Liste -> Bereich, Einzelwert -> Gleichheit) oder in der Form des Spaltenfilters, wenn dessen Einstellungen benötigt werden:
{{{#!xml <table filter-bar="true" personalization-key="demo-attributes" types="demo.react:Demo">
<inputs>
<input channel="selected"/>
</inputs>
<presets>
<preset name="mine">
<label>
<en>My open items</en>
<de>Meine offenen Einträge</de>
</label>
<criterion column="owner" expr="currentUser()"/>
<criterion column="active" expr="true"/>
<criterion column="amount" expr="list(100, 200)"/>
</preset>
<preset name="odd-values">
<label><en>Odd values</en><de>Ungerade Werte</de></label>
<criterion column="string">
<text pattern="'^Value [135]$'" regexp="true" case-sensitive="true" whole-field="true"/>
</criterion>
</preset>
<preset name="large-amount">
<label><en>Amount over 50</en><de>Betrag über 50</de></label>
<criterion column="amount" inverted="true">
<range operator="BETWEEN" primary="0" secondary="50"/>
</criterion>
</preset>
<preset name="same-priority">
<label><en>Same priority as the selection</en><de>Gleiche Priorität wie die Auswahl</de></label>
<criterion column="priority">
<options selected="sel -> $sel.get(demo.react:Demo#priority)"/>
</criterion>
</preset>
</presets>
<columns>
...
</columns>
</table> }}}
Die Formen spiegeln die Filterzustände: <text pattern= case-sensitive= regexp= whole-field=>, <range operator="EQ|NE|LT|LE|GT|GE|BETWEEN" primary= secondary=>, <options selected=> und <boolean accept=>; inverted="true" am Kriterium kehrt es um. Alle Werte (pattern, primary, secondary, selected, accept und expr) sind TL-Script-Ausdrücke; die Struktur (Operator, Vergleichsoptionen, Umkehrung) ist fest deklariert. Ein Ausdruck ist eine Konstante oder eine Funktion der Werte der <inputs>-Kanäle der Tabelle in Deklarationsreihenfolge - dieselbe Konvention wie beim Ausdruck rows. Damit kann ein Preset sich auf das beziehen, was anderswo angezeigt wird (das gewählte Projekt, der aktuelle Benutzer).
Die Ausdrücke werden beim Aufbau der Tabelle ausgewertet und erneut, sobald sich ein Eingabekanal ändert; ein aktives konfiguriertes Preset wird dabei mit seinen neuen Kriterien erneut angewendet, folgt also der Auswahl. Ein Kriterium, dessen Wert nichts auswählt (etwa weil nichts gewählt ist), lässt seine Spalte ungefiltert; ein Preset, dessen Kriterien sämtlich nichts auswählen, wird solange nicht angeboten. Ein Preset ohne Kriterien ist der Chip "alle Zeilen", der genau dann aktiv ist, wenn die Tabelle ungefiltert ist. Ein Kriterium, das nicht zum Filter seiner Spalte passt (falsche Form, unbekannter Auswahlwert, Umkehrung an einem nicht umkehrbaren Filter, unbekannte Spalte), ist ein Konfigurationsfehler beim Start; das betroffene Preset wird dann nicht angeboten. Die aufgelösten Zustände werden über die Serialisierung des Spaltenfilters normalisiert, so dass ein Preset und dieselbe im Dialog eingestellte Kombination als gleich erkannt werden und der Chip abgeleitet aufleuchtet. Preset-Namen sind je Tabelle eindeutig (@Key), so dass eine Ansichts-Überlagerung ein Preset über seinen Namen ersetzen kann.
Technische Umsetzung
Modellschicht (`com.top_logic.table`)
Die kombinierte Filterbedingung einer Tabelle war bisher ausschließlich spaltenweise definiert (FilterSpec als Abbildung Spaltenname -> FilterState, UND-verknüpft; ColumnLogic.predicate weist jeden Schlüssel ab, der keine Spalte ist). Eine Freitextsuche ist dagegen eine ODER-Verknüpfung über Spalten und lässt sich deshalb nicht als Pseudospalte abbilden. FilterSpec erhält daher einen zweiten, gleichberechtigten Bestandteil für die spaltenübergreifende Suche (SearchSpec), den ColumnLogic mit den Spaltenfiltern UND-verknüpft. Die In-Memory-Quellen (ListRowSource, TreeRowSource) benötigen dadurch keine eigene Logik.
Weitere Ergänzungen:
- Column erhält eine überschreibbare Methode searchText(row), die den durchsuchbaren Text einer Zeile liefert; die Voreinstellung leitet ihn aus dem dargestellten Zellinhalt ab, DefaultColumn.Builder.searchText(...) erlaubt eine Spalte, deren Zelle ein Steuerelement ist, dennoch durchsuchbar zu machen. Damit durchsucht die Freitextsuche genau das, was der Anwender sieht.
- Die Vergleichslogik des Textfilters (Teilstring bzw. regulärer Ausdruck, Groß-/Kleinschreibung, ganzes Feld) lag privat im TextColumnFilter. Sie liegt jetzt in TextFilterState.matcher(), so dass Spaltenfilter und Freitextsuche nachweislich identisch vergleichen.
- ColumnFilter erhält eine Übersetzungsschnittstelle stateFor(Object), die aus einem ausgewerteten Wert den FilterState des jeweiligen Filters erzeugt (Text: Zeichenkette; Auswahlfilter: Wert oder Wertemenge, wodurch Referenzen unmittelbar funktionieren; Wahrheitswert; Vergleichsfilter: zweielementige Liste als Bereich, Einzelwert als Gleichheit; Regexp-Facetten: Facettenschlüssel); das ist die Kurzform expr. Die ausführliche Form eines Kriteriums (FilterStateConfig: text/`range`/`options`/`boolean`) wird in der Ansichtsschicht in den passenden Filterzustand übersetzt und gegen die Eingabeart des Spaltenfilters geprüft. Ein Filter, der ein konfiguriertes Kriterium nicht ausdrücken kann, führt zu einem Konfigurationsfehler beim Start und nicht zu einem stillschweigend übergangenen Kriterium.
- Benannte Filterkombinationen (konfigurierte Presets und benutzereigene gespeicherte Filter) werden durch einen Wertetyp NamedFilter beschrieben, so dass die Filterleiste nur einen Mechanismus kennt. TableView bietet namedFilters(), activeNamedFilter() (abgeleitet), applyNamedFilter(id), saveNamedFilter(name), deleteNamedFilter(id) und search(...).
- Der Suchbegriff wird Teil des Ansichtszustands (TableViewState) und seiner Serialisierung; da der Leser unbekannte Einträge toleriert, bleiben bereits gespeicherte Personalisierungen gültig.
Persistenz der gespeicherten Filter
Die gespeicherten Filter werden - analog zur bestehenden Personalisierung von Spaltenbreiten und Sortierung - als JSON in der PersonalConfiguration des Anwenders abgelegt (NamedFilterStore, Schlüssel tableFilters.<tableId>) und über die bereits vorhandene, spaltenbezogene Serialisierung der Filterzustände umgewandelt. Es ist dafür kein neuer persistenter Modelltyp und keine Datenmigration erforderlich.
Dabei wird ein Mangel der bestehenden Personalisierung behoben: Der Schlüssel, unter dem eine Tabelle ihren Zustand ablegt, wurde bisher aus den Typen und den Namen der konfigurierten Spalten gebildet. Wird einer Ansicht eine Spalte hinzugefügt, ändert sich dieser Schlüssel, und der gespeicherte Zustand ist verwaist; zwei strukturgleiche Tabellen in verschiedenen Ansichten teilen sich umgekehrt einen Eintrag. Für Spaltenbreiten ist das lästig, für benannte gespeicherte Filter wäre es ein Datenverlust bei jeder Erweiterung der Ansicht. Das <table>-Element wertet deshalb das bereits an jedem Oberflächenelement dokumentierte Attribut personalization-key aus, mit dem eine stabile Kennung vergeben wird; ohne Angabe bleibt es beim bisherigen, aus der Struktur abgeleiteten Schlüssel, so dass bestehende Personalisierungen erhalten bleiben.
Oberfläche
Die Filterleiste wird im TableViewControl bereitgestellt - also an derselben Stelle wie der bereits vorhandene Erweiterungspunkt für spaltenspezifische Filteroberflächen - und deklarativ vom <table>-Element aktiviert (filter-bar). Damit können auch die übrigen Tabellen (etwa die editierbare Tabelle RowSetTableControl) die Leiste nutzen. Auf der Client-Seite entsteht oberhalb des Spaltenkopfbereichs (außerhalb der horizontal scrollenden Bereiche) ein Bereich mit den Chips, dem Suchfeld und der Speichern-Schaltfläche; die Kommandos applyNamedFilter, clearFilter, search, saveNamedFilter und deleteNamedFilter sind wie die übrigen Tabellenkommandos typisiert und aufzeichenbar. Strg+A in einem Texteingabefeld innerhalb einer Tabelle markiert jetzt den Text des Feldes, nicht mehr alle Zeilen.
Zahlenwerte in der Sprache des Anwenders
Die Freitextsuche prüft den dargestellten Zellentext. Dabei zeigte sich, dass das Zahlen-Eingabefeld der React-Oberfläche (ReactNumberInputControl, das auch die Zahlenzellen der Tabelle darstellt) die Sprache des Anwenders ignorierte: Ein Wert wurde in einer deutschen Sitzung als 12.5 statt 12,5 angezeigt, und eine deutsche Eingabe 12,5 wurde als ungültig abgewiesen; Datumswerte waren dagegen bereits korrekt lokalisiert.
Das Eingabefeld arbeitet jetzt mit einem NumberFormat: Der Wert wird damit für den Client formatiert (in Anzeige- und Bearbeitungsmodus), und die Eingabe wird mit demselben Format streng eingelesen (der gesamte Text muss eine Zahl sein). Für Modellattribute liefert die <format>-Annotation des Attributs bzw. seines Typs das Format (ein beliebiges java.text.Format, etwa das Dauer-Format von tl.core:Duration), andernfalls das Standardformat des Anwenders für Fließkomma- bzw. Ganzzahlen (Formatter.getDoubleFormat() / getLongFormat(), wie bei den Formularfeldern der klassischen Oberfläche; ein Fließkommawert erscheint daher mit zwei Nachkommastellen, z. B. 12,50). Dasselbe Format bestimmt den Suchtext einer Zahlenspalte und das Einlesen von Grenzen im Spaltenfilter, so dass Anzeige, Eingabe, Filter und Suche übereinstimmen (FieldSpec.getNumberFormat(), FieldControlService.numberFormat(part), com.top_logic.basic.format.NumberFormats). Die Grenzen eines Vergleichsfilters (ComparableColumnFilter, neue Schnittstelle BoundCodec) unterscheiden jetzt zwischen dem Text für den Anwender (in dessen Sprache formatiert und eingelesen) und der sprachunabhängigen Speicherform (Zahl bzw. ISO-8601-Datum); zuvor wurde die Grenze als toString()-Text abgelegt und mit dem Eingabe-Parser zurückgelesen, was für Datumsgrenzen nie funktionierte und für Zahlen von der Sprache des Anwenders abhinge. Beim Tippen wird der Wert erst beim Verlassen des Feldes übertragen, damit der normalisierte Text (12,5 -> 12,50) die Eingabe nicht während des Tippens überschreibt.
Nebenbei behoben: Die Format-Hüllen in com.top_logic.basic.format (DoubleFormat, LongFormat, NormalizingFormat) und die Thread-Stellvertreter des Formatter (NumberFormatProxy, DateFormatProxy) leiteten nur das Formatieren und Einlesen an das umhüllte Format weiter, nicht aber die lesenden Zugriffe wie getMaximumFractionDigits() oder getTimeZone(). Sie erben jetzt von einer gemeinsamen Basis NumberFormatDecorator bzw. leiten diese Zugriffe weiter.
Nachweis
Die Modellschicht ist durch Testfälle abgedeckt (spaltenübergreifende Suche einschließlich der Verknüpfung mit Spaltenfiltern, Ableitung des aktiven Presets, Erzeugung der Filterzustände aus konfigurierten Werten, Speichern und Wiederherstellen benutzereigener Filter, Auflösung der konfigurierten Presets, lokalisierte Zahlenein- und -ausgabe, Format-Einstellungen der Stellvertreter). Die Oberfläche wurde in der React-Demoanwendung an der Tabelle der Startseite geprüft, die alle Filterarten enthält.