Skip to content

UI: Unify list page filters and table header controls - #71731

Merged
ryanahamilton merged 14 commits into
apache:mainfrom
astronomer:filtering
Aug 18, 2026
Merged

UI: Unify list page filters and table header controls#71731
ryanahamilton merged 14 commits into
apache:mainfrom
astronomer:filtering

Conversation

@ryanahamilton

@ryanahamilton ryanahamilton commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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 FilterBar

src/pages/DagsList/DagsFilters/ was a parallel implementation: eight
always-visible bespoke controls, its own chakra-react-select wrapper, its own
URL read/write and pagination reset. It shared no code with FilterBar, which
the other ten list pages use.

Adopting FilterBar first required teaching it three things it could not
express:

  • Repeated URL params. useFiltersHandler only ever called set(), so
    tags, teams, owners and timetable_type had no representation. This
    also meant the existing TEAMS filter could hold a single value even though
    DagRuns and TaskInstances already read it with getAll() — a latent bug
    this fixes.
  • Booleans. Previously faked as a select over "true"/"false" strings.
    The new boolean type activates in one click. Its value is still the string
    "true": isValidFilterValue treats any non-empty value as set, so a real
    boolean would write needs_review=false into the URL and reintroduce the
    tri-state ambiguity the type exists to remove.
  • Async option lists. Tags and timetable types need search-as-you-type over
    a paginated endpoint, which a static options array cannot express. These are
    supplied via an optional EditorComponent on the config, so each editor owns
    its 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:

  • Pills snapshot their config when created, which for URL-seeded filters happens
    before the i18n namespaces resolve, so deep-linked pills rendered raw
    translation keys as labels. This affected every FilterBar page.
  • 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. It
    now has a removable pill.

Where hide_paused_dags_by_default is set, the default is written into the URL
so 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:

file-f484ee7d48bc2f46fe02362c3c1bd5ad

Default state - after:

file-6300bdf6d25fe278e94f5024d95e4cd5

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.

Before After
Zight Recording 2026-08-17 at 03 06 37 PM Zight Recording 2026-08-17 at 03 07 19 PM

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 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.ts keep their "all" entries — the graph task filter
still uses them and is not a FilterBar pill.

Table controls move into the table header

DataTable gains three slots, each documented with the kind of control it
expects, because the distinction is otherwise easy to get wrong:

Slot Position Holds
filterActions left, under the heading changes which rows come back — SearchBar, FilterBar
presentationActions right, with the columns menu changes how returned rows are drawn — sort selects, expand/collapse
primaryActions right, level with the heading the page's calls to action — "Add Pool", "Add Variable"

The former actions prop is renamed presentationActions; the old name gave no
hint 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

  • The TEAMS single-to-multi conversion is the change most worth a careful
    look.
    Blast radius is two pages (DagRunsFilters, TaskInstancesFilter);
    both already read getAll() and pass Array<string>, and the OpenAPI type is
    Array<string>. Existing ?teams=x URLs keep working; the only visible change
    is that "All Teams" is replaced by removing the pill. It is separable into its
    own commit if preferred.
  • I could not exercise TEAMS live — multi_team is disabled on the instance I
    tested against. It is covered by type-checking and unit tests only.
  • New i18n keys are English-only; the other 20 locales fall back at runtime,
    which is the sanctioned path here (check-translations-completeness is a
    warning, and AGENTS.md forbids placeholder translations).
  • Dropping the "all" options leaves dags:filters.allStates, allRunTypes and
    the equivalent job keys in place, since stateOptions.ts still feeds them to
    the graph task filter.

Testing

tsc and eslint clean; 944/944 vitest tests pass, including new coverage for
multiselect round-tripping in useFiltersHandler, the boolean and custom-editor
paths in FilterBar, all three DataTable slots, and filters being dropped when
left 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 affected
pages were driven in a browser with no console errors.


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Opus 5)

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.
@boring-cyborg boring-cyborg Bot added area:translations area:UI Related to UI/UX. For Frontend Developers. translation:default labels Aug 17, 2026

@choo121600 choo121600 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 bbovenzi added this to the Airflow 3.4.0 milestone Aug 18, 2026

@bbovenzi bbovenzi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall lgtm.

A few comments plus we need to rebase.

Comment thread airflow-core/src/airflow/ui/src/components/FilterBar/filters/TagsFilter.tsx Outdated
Comment thread airflow-core/src/airflow/ui/src/components/MatchModeToggle.tsx Outdated
Comment thread airflow-core/src/airflow/ui/src/components/FilterBar/filters/MultiSelectPill.tsx Outdated
Comment thread airflow-core/src/airflow/ui/src/utils/useFiltersHandler.test.tsx
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.
@ryanahamilton
ryanahamilton merged commit 1f82ed8 into apache:main Aug 18, 2026
89 checks passed
@ryanahamilton
ryanahamilton deleted the filtering branch August 18, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:translations area:UI Related to UI/UX. For Frontend Developers. translation:default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants