enhancement
major
minor
major
minor
major
#29605
TL Views table filter bar: an initially active preset, and the active preset and search term as channels for query bindings
Problem
The filter bar of <table> (TableElement, com.top_logic.layout.view) offers declared <presets> as chips, a free-text search over the displayed columns and saved filters per user. Two things it cannot express, found while replacing the quick-filter toolbar commands of the ticket list in the issue tracker tl-dev by presets:
1. No preset can be declared initially active
A table opens unfiltered; the preset without criteria ("All") is the one marked. The user's last choice comes back through the personalization under the table's personalization-key, but a first visit, a new user and a scripted test all see the whole row set. A ticket list whose sensible default is "the open tickets" (1 873 of 15 182 rows in the imported data) therefore shows everything until the user clicks. With the former toolbar commands the unset channel meant "open"; that default is lost with the presets.
2. The active preset and the search term have no channel
TableElement reads its inputs and writes the selection channel, but neither the active named filter nor the search term reach a channel. <query-bindings> (QueryBindingParticipant, #29530) binds channels only, so a filtered list has no URL: /view/tickets?filter=mine was possible with a channel written by a toolbar command and is not possible with the bar. A link a developer hands on ("the unassigned tickets") cannot name the preset, and a reload restores the filter from personalization rather than from the address.
Lösung
Initially active preset
<presets initial="open"> names the preset that is applied when the table is created for a session that has no personalized view state for it. The attribute sits on the container, not on the individual <preset>, so exactly one place can name the default and an unknown name is reported as a configuration error at startup.
The decision "is there a personalization?" is only known inside DefaultTableView.restore(), which runs in the constructor and is the single place already implementing the "personalization wins over the configured default" rule (the same rule the default sort and the initial grouping follow). The view therefore takes the initial named filter id and applies it at the end of restore() exactly when the ViewStateStore held nothing for this TableId. Once the user has decided about the filtering themselves, the personalization wins - including the decision to clear the filter. Applying the initial preset persists nothing itself, so an unpersonalized table starts from it in every session and follows it when its criteria are redefined.
Active preset and search term as channels
Two optional channel bindings on <table>, declared like the existing selection (and like range-start/`range-end` of <calendar>):
- active-preset="channel" — the id of the named filter the table matches, null while it matches none. Written by the bar, applied when written from outside. The id is whatever NamedFilter.id() is, that is a declared preset name or, for one of the user's own saved filters, its generated id.
- search-term="channel" — the free text of the bar's search, two-way.
Both are ordinary view channels, so <query-bindings> puts them into the address (?filter=mine&q=…, emitted with replaceState as every query-only change), a <value-input> elsewhere can edit the term (the app bar search of the issue tracker), and a command can switch the preset. A table that displays no bar at all is searched through its channel just the same, and several tables bound to one search channel are searched together.
An id written to active-preset that matches no named filter leaves the table unfiltered and the binding writes the actual state back, so a stale or mistyped address corrects itself.
A search narrows within the preset
For the two channels to describe one set of rows, the search term and the named filter had to become independent of each other. NamedFilter.matches() compared the search term unconditionally, and a declared preset never carries one (DeclaredFilters builds every declaration without a search), so any search ended the match although the criteria behind the chip were untouched: the table went on showing the preset's rows searched, while the address named only the search and reproduced a different row set on reload.
The search term now takes part in the comparison only for a filter that defines one. A declared preset matches on its columns alone, so the chip stays active while the user searches within it, and ?filter=mine&q=login is the address of "the tickets I commented on, searched for login" - it round-trips. A filter the user saved while searching does carry the term and keeps comparing it exactly, and such a filter, being the more complete description of the same rows, is the active one where a search-agnostic preset with the same columns would match as well. Picking a chip still replaces the whole filtering, the search term included.
Because every writer sets one channel at a time - a query binding writes one bound parameter after the other - a write to either channel brings the table to the state both of them describe, first the named filter and then the term. Applying only the channel that changed would let the outcome depend on which of two independent writes came last, so that ?filter=mine&q=login worked or lost its search term depending on the order of the <bind> elements.
Technische Umsetzung
The active named filter is derived, not stored, so the notification has to come from the view rather than from the filter bar control:
- TableViewListener gets a filterChanged() callback (a default method, so existing listeners are unaffected), fired by DefaultTableView whenever the column filters, the search term or the offered named filters change - including setDeclaredFilters(), which re-applies the active declared filter whenever an input of the table changes, and the saving and deleting of a filter, which change which filter is active without changing any criterion.
- A TableFilterBinding next to TableSelectionBinding connects that callback to the two channels two-way, with a re-entrancy guard and dispose() on cleanup. TableElement establishes it in both the read-only and the editable path; the editable RowSetTableControl rebuilds its inner table control on every edit-mode switch and re-establishes the binding like it does the selection binding.
- The binding drives the table through TableViewControl rather than through the view, so a change arriving on a channel re-renders the chips and the search box like one made in the bar. Its applyNamedFilter, clearFilter and search are the command handlers' own implementations, made accessible for that purpose.
Observing the view rather than the filter bar control also makes the channels work for a table whose bar is hidden (TableViewControl.refreshFilterBar() returns early in that case).
No change to <query-bindings> and no new client code are needed: the React filter bar already renders the activeNamedFilter and search state the server pushes.
Dokumentation
A filter bar section in docs/faq/react-view-layer.md, and the object list demo of com.top_logic.demo.react using presets with an initial one, both channels and the resulting query bindings.