UI: Unify list page filters and table header controls - #71731
Merged
Conversation
Every list page in the UI filters through FilterBar except the Dags list, which carried its own parallel implementation: eight always-visible bespoke controls, a separate multi-select wrapper, and its own URL read/write and pagination reset. The busiest page in Airflow therefore filtered differently from everywhere else, and shared behaviour like preset filters only half applied. Adopting FilterBar first required teaching it three things it could not express. Repeated URL params (tags, teams, owners, timetable types) had no representation at all, since useFiltersHandler only ever called set() — which also meant the existing TEAMS filter could hold a single value even though its consumers already read it with getAll(). One-click on/off filters were faked as selects over "true"/"false" strings. Option lists that come from a paginated endpoint could not be expressed by a static array, so those editors are now supplied through an optional EditorComponent and run their query only while their pill is mounted, rather than on every render of the page. A boolean filter stores the string "true" rather than a real boolean: isValidFilterValue treats any non-empty value as set, so a real boolean would write "false" into the URL and reintroduce the tri-state ambiguity the type exists to remove. Two defects fall out of the move. Pills snapshot their config when created, which for filters seeded from the URL happens before the i18n namespaces resolve, so deep-linked pills rendered raw translation keys as labels. And the owners param filtered the list with no control anywhere, reachable only by clicking an owner link and impossible to clear without editing the URL. Where hide_paused_dags_by_default is set, the default is now written into the URL so it appears as a pill instead of filtering invisibly. Reset re-seeds it deliberately, so Reset stays idempotent with a cold load; choosing "All" remains the way to see paused Dags, as before.
Every list page arranged its own controls above the table, so search boxes, filter bars, sort selects and "add" buttons landed in a different place, and at a different size, on each one. Moving them into the table header gives the whole UI a single arrangement and frees the vertical space they used to occupy. DataTable now exposes three slots, each documented with the kind of control it expects, because the distinction is otherwise easy to get wrong: filterActions changes which rows come back, presentationActions changes how the rows that came back are drawn, and primaryActions holds the page's calls to action. Grouping the first two on opposite sides of the row keeps "what you are looking at" separate from "how it is displayed". Each slot has to be named in the header row's render condition. Missing one is invisible until a table has nothing else to put in the row — the row count heading is suppressed while loading, so the controls would disappear on exactly the tables that show least. The former `actions` prop becomes `presentationActions`; the old name gave no hint which of the three kinds of control belonged in it.
The pill rounds and clips its own corners, and the table header it now sits in is inside a scroll container, so the menu react-select renders inline was hidden by one of those ancestors before it ever reached the viewport. The options were fetched and rendered the whole time — a menu with ten tags in it measured its full size, just clipped out of sight. Rendering it in a portal takes it out of reach of every clipping ancestor rather than only the one the pill owns. Also fix the timetable-type assertion, which looked up each restored value as its own text node. The collapsed pill now joins its values into one string, so the values have to be matched against the pill's text content instead.
Adding a filter from the menu left it sitting inert: the pill appeared but the control behind it stayed shut, so choosing a value took a second click that gave no hint it was needed. Selects and multiselects now open onto their options the way the date range picker already did, and text and number inputs keep the focus they were already given. Leaving an editor without choosing anything now drops the filter rather than parking a valueless pill on the bar. Such a pill reads as an active filter while filtering nothing, and the only way to be rid of it was to notice it and remove it by hand. Editors that dismiss themselves — anything with a popover or a portalled menu — never see a blur, because closing hands focus back inside the pill. They get an explicit way to ask the pill to close instead. That is passed separately from the props editors spread onto their element, since React warns about handlers it does not recognise reaching the DOM. Two ordering hazards are worth knowing about. A select commits its value in the same tick as it closes, before React re-renders, so the close is matched against a record of the selection rather than against a value that has not landed yet. And focus arrives a frame after the editor opens, which cancels any blur raised before then — the reason the tests wait for focus before simulating one.
Entering an owner name and pressing Enter threw the filter away. The keypress reached both the select, which uses Enter to commit the value being created, and the pill wrapping it, which treats Enter as "done editing" — and since the value had not landed yet the pill saw an empty filter and dropped it. The same applied to choosing an option with the keyboard in any single select. Both selects now leave Enter and Escape to the control that owns them. The pill still closes on Escape, by way of the menu reporting that it closed. Known gap, unchanged by this: a single select opened automatically has no focus, so its options cannot be reached by keyboard. Fixing that means opening it through its trigger, which works in a browser but not under happy-dom, so it is left for its own change rather than smuggled in behind a test workaround.
Picking "All states" matches every state, which is the same as not filtering at all — and since leaving a filter unset now removes it, the option was just a second way to say something the pill already says by not being there. The equivalent option is kept on the run state filter used elsewhere, whose pills behave the same but which was not part of this change.
Selecting "all" matches everything, which is the same as not filtering. Now that a filter left unset is dropped from the bar, its absence already says so, and offering the choice as well gives two ways to spell one thing — one of which still looks like an active filter. It went from every select that had it: run state, paused state, run type, job state and type, task instance state, deadline status, and the HITL response state. Paused needed more than the option removed. Its "all" existed because clearing the param let the hide_paused_dags_by_default seeding put the filter straight back, so the option was the only way to reach paused Dags. The seeding now happens once per page load, and the list no longer re-applies the default when the param is absent, which makes removing the pill mean what it says. The cost is that a fresh load re-applies the default, which is what a deployment setting should do. The lists in stateOptions.ts keep their "all" entries: the graph task filter still uses them, and it is not a FilterBar pill.
Nothing asserted that hide_paused_dags_by_default reaches the URL, only that the list came back filtered — which a query-level default would also satisfy. The point of seeding is that the default is visible and removable, so the pill itself is worth pinning down.
ryanahamilton
commented
Aug 17, 2026
ryanahamilton
marked this pull request as ready for review
August 17, 2026 20:28
ryanahamilton
requested review from
bbovenzi,
choo121600,
guan404ming,
pierrejeambrun,
shubhamraj-git and
vatsrahul1001
as code owners
August 17, 2026 20:28
1 task
choo121600
reviewed
Aug 18, 2026
choo121600
left a comment
Member
There was a problem hiding this comment.
The UI looks much cleaner!
Could you also update the UI E2E tests to align with the new implementation?
The helper clicked the select trigger before choosing an option, which was needed while a newly added filter opened shut. Now that adding a filter opens it onto its options, that click closed the menu again and the option was never there to click. Only a pill being reopened still needs it, because a filter that already holds a value does not reopen on its own.
bbovenzi
approved these changes
Aug 18, 2026
bbovenzi
left a comment
Contributor
There was a problem hiding this comment.
Overall lgtm.
A few comments plus we need to rebase.
main reworked FilterBar at the same time, so the two collided in five files. Its changes were mechanical — dropping the React type namespace for direct imports — and were taken alongside the filter work rather than instead of it: the pill keeps its two-argument renderInput and drop-when-unset behaviour, the icon map keeps its boolean and multiselect entries, and the config keeps its editor, creatable and match-mode fields, all now typed the way main types them. The i18n conflict was two keys added either side; both are kept.
Drop the redundant namespace argument from three useTranslation calls: common is already the default namespace, so the unqualified keys resolve either way. Name the two multiselect pill widths. The values are chosen relative to the single-value editors, which the literals gave no way to tell. Cover boolean filters in the useFiltersHandler tests, including that an explicit `needs_review=false` reads as unset — it means the same as no filter, and must not bring the pill back.
Adding a filter such as Tags left the caret nowhere, so typing did nothing until the field was clicked. The select now takes focus itself, a frame after opening: any earlier and it loses the focus race against the pill and against the Add Filter menu handing focus back to its trigger, and losing that race blurs the pill, which discards the filter that was just added. The focus ring came from the select's own control, which is squarer than the pill around it, so its right end disappeared into the rounded corner. The pill draws the ring now, following its own shape. Picking a value also leaves the menu up. On a multiselect the next pick is usually the point, and reopening for each one is busywork. Tooltips are portalled so they escape the stacking context they sit in — the Clear Search tooltip was painted over by the button below it, which a z-index alone cannot fix from inside a lower context. The timetable-type test drove react-select through several selections, which only worked while the field was unfocused: fireEvent does not preserve focus the way a real mousedown does, so the pill collapses between picks. It is now two tests covering the same ground — filtering by a type, and clearing it — without depending on that choreography.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every list page in the UI arranged its own controls above the table, so search
boxes, filter bars, sort selects and "add" buttons landed in a different place —
and at a different size — on each one. The Dags list went further and had a
filtering implementation entirely of its own.
This makes both consistent.
Dags list moves onto the shared
FilterBarsrc/pages/DagsList/DagsFilters/was a parallel implementation: eightalways-visible bespoke controls, its own
chakra-react-selectwrapper, its ownURL read/write and pagination reset. It shared no code with
FilterBar, whichthe other ten list pages use.
Adopting
FilterBarfirst required teaching it three things it could notexpress:
useFiltersHandleronly ever calledset(), sotags,teams,ownersandtimetable_typehad no representation. Thisalso meant the existing
TEAMSfilter could hold a single value even thoughDagRunsandTaskInstancesalready read it withgetAll()— a latent bugthis fixes.
"true"/"false"strings.The new
booleantype activates in one click. Its value is still the string"true":isValidFilterValuetreats any non-empty value as set, so a realboolean would write
needs_review=falseinto the URL and reintroduce thetri-state ambiguity the type exists to remove.
a paginated endpoint, which a static
optionsarray cannot express. These aresupplied via an optional
EditorComponenton the config, so each editor ownsits query and only runs it while its pill is mounted — an improvement on the
old page, which fired both queries on every render whether used or not.
Full recording of new FilterBar implementation: https://r.hmlt.in/01a0112e-aaff-7774-8ec3-37b930b0f0c5
Two defects fall out of the move:
before the i18n namespaces resolve, so deep-linked pills rendered raw
translation keys as labels. This affected every
FilterBarpage.ownersparam filtered the list with no control anywhere — reachable onlyby clicking an owner link, and impossible to clear without editing the URL. It
now has a removable pill.
Where
hide_paused_dags_by_defaultis set, the default is written into the URLso it shows as a pill rather than filtering invisibly. It is seeded once per
page load, so removing the pill is how you ask to see paused Dags.
Default state - before:
Default state - after:
Every filter loses its "all" option
Selecting "all" matches everything, which is the same as not filtering. Now that
a filter left unset is dropped from the bar, its absence already says so, and
offering the choice as well gave two ways to spell one thing — one of which
still looked like an active filter.
It is gone from every select that had it: run state (last run, any run, and the
shared state filter), paused state, run type, job state, job type, task instance
state, deadline status, and the HITL response state.
Paused needed more than the option removed. Its "all" existed because clearing
the param let the
hide_paused_dags_by_defaultseeding put the filter straightback, so the option was the only route to paused Dags. Seeding now happens once
per page load, and the list no longer re-applies the default when the param is
absent, which makes removing the pill mean what it says. A fresh load re-applies
the default, which is what a deployment setting should do.
The lists in
stateOptions.tskeep their "all" entries — the graph task filterstill uses them and is not a FilterBar pill.
Table controls move into the table header
DataTablegains three slots, each documented with the kind of control itexpects, because the distinction is otherwise easy to get wrong:
filterActionsSearchBar,FilterBarpresentationActionsprimaryActionsThe former
actionsprop is renamedpresentationActions; the old name gave nohint which of the three kinds of control belonged in it. Fourteen pages were
migrated.
Each slot has to be named in the header row's render condition. Missing one is
invisible until a table has nothing else to put in the row — the row count
heading is suppressed while loading, so the controls would disappear on exactly
the tables that show least. There are regression tests for all three.
Notes for reviewers
TEAMSsingle-to-multi conversion is the change most worth a carefullook. Blast radius is two pages (
DagRunsFilters,TaskInstancesFilter);both already read
getAll()and passArray<string>, and the OpenAPI type isArray<string>. Existing?teams=xURLs keep working; the only visible changeis that "All Teams" is replaced by removing the pill. It is separable into its
own commit if preferred.
TEAMSlive —multi_teamis disabled on the instance Itested against. It is covered by type-checking and unit tests only.
which is the sanctioned path here (
check-translations-completenessis awarning, and
AGENTS.mdforbids placeholder translations).dags:filters.allStates,allRunTypesandthe equivalent job keys in place, since
stateOptions.tsstill feeds them tothe graph task filter.
Testing
tscandeslintclean; 944/944 vitest tests pass, including new coverage formultiselect round-tripping in
useFiltersHandler, the boolean and custom-editorpaths in
FilterBar, all threeDataTableslots, and filters being dropped whenleft unset. Every filter that used to offer "all" was opened in a browser to
confirm the option is gone. The Dags e2e spec passes
apart from one failure that reproduces on unmodified
main. All twelve affectedpages were driven in a browser with no console errors.
Was generative AI tooling used to co-author this PR?