From 5aa8d1eb1ce9d58b545492443049239e681a3b45 Mon Sep 17 00:00:00 2001 From: rjgoyln Date: Sun, 23 Aug 2026 20:32:32 +0800 Subject: [PATCH 1/2] UI: Stop list pages jumping while a filter loads Adding or removing a filter starts a fresh query, and the row count heading that sits directly above the search and filter controls used to unmount for the duration. The controls slid up and dropped back once results arrived, so every filter the user touched made the page jump. The Dags and Assets lists had no previous results to fall back on either, so they swapped their rows for skeletons whose fixed widths resized the columns as they came and went. --- airflow-core/newsfragments/72005.bugfix.rst | 1 + .../components/DataTable/DataTable.test.tsx | 14 ++---- .../ui/src/components/DataTable/DataTable.tsx | 17 +++---- .../src/pages/AssetsList/AssetsList.test.tsx | 40 ++++++++++++++++- .../ui/src/pages/AssetsList/AssetsList.tsx | 23 ++++++---- .../ui/src/pages/DagsList/DagsList.test.tsx | 44 ++++++++++++++++++- .../ui/src/pages/DagsList/DagsList.tsx | 3 +- .../src/airflow/ui/src/queries/useDags.tsx | 2 + 8 files changed, 111 insertions(+), 33 deletions(-) create mode 100644 airflow-core/newsfragments/72005.bugfix.rst diff --git a/airflow-core/newsfragments/72005.bugfix.rst b/airflow-core/newsfragments/72005.bugfix.rst new file mode 100644 index 0000000000000..f8587721d7fd5 --- /dev/null +++ b/airflow-core/newsfragments/72005.bugfix.rst @@ -0,0 +1 @@ +Stop list pages shifting while a filter loads. The row count heading no longer disappears mid-load, so the search and filter controls beneath it stay put, and the Dags and Assets lists keep their current rows on screen until the filtered ones arrive. diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.test.tsx b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.test.tsx index ac8d5cf1ca5d6..83c2aaa1316ee 100644 --- a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.test.tsx +++ b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.test.tsx @@ -289,12 +289,12 @@ describe("DataTable", () => { expect(screen.getByRole("heading")).toHaveTextContent("50,000+ task"); }); - it("does not render row count heading during the initial load", () => { + it("keeps the row count heading during the initial load", () => { render(, { wrapper: ChakraWrapper, }); - expect(screen.queryByRole("heading")).toBeNull(); + expect(screen.getByRole("heading")).toHaveTextContent(/^task_other$/u); }); it("does not render row count heading when hideRowCountHeading is set", () => { @@ -469,7 +469,7 @@ describe("DataTable", () => { }); // Each slot needs its own entry in the header row condition, or its content vanishes whenever - // nothing else occupies the row — including while loading, when the row count is suppressed. + // nothing else occupies the row — as it does on tables that hide the row count heading. const actionSlots = [ ["filterActions", { filterActions: }], ["presentationActions", { presentationActions: }], @@ -482,14 +482,6 @@ describe("DataTable", () => { { wrapper: ChakraWrapper }, ); - expect(screen.getByText("slot content")).toBeInTheDocument(); - }); - - it.each(actionSlots)("keeps %s visible while loading", (_name, slot) => { - render(, { - wrapper: ChakraWrapper, - }); - expect(screen.queryByRole("heading")).toBeNull(); expect(screen.getByText("slot content")).toBeInTheDocument(); }); diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx index 1cd5a43dbd97c..6e23b4312e8d3 100644 --- a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx +++ b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx @@ -206,8 +206,6 @@ export const DataTable = ({ (pageIndex !== 0 || rows.length !== rowTotal); const translateModelName = (count: number) => translate(modelName, { count }); - // During the initial load there is nothing to count yet - const showRowCount = !Boolean(hideRowCountHeading) && !Boolean(isLoading); const noRowsNode = noRowsMessage ?? translate("noItemsFound", { modelName: translateModelName(0) }); // i18next derives the plural form from the count, but in some languages (Russian) no integer count // ever selects `_other`, so read the count-free plural key directly instead of passing a stand-in. @@ -221,15 +219,18 @@ export const DataTable = ({ display === "table" && (showColumnsMenu ?? columns.length > 5) && table.getAllLeafColumns().some((column) => column.getCanHide()); - const headingNode = showRowCount ? ( + const hasRowCount = total !== undefined && !Boolean(isLoading); + // `filterActions` sits directly below this heading, so unmounting it while the count is unknown + // drags the filter controls up and drops them back once results arrive. + const headingNode = Boolean(hideRowCountHeading) ? undefined : ( - {total === undefined - ? pluralModelName - : `${total.toLocaleString(i18n.language)}${isCapped ? "+" : ""} ${translateModelName(total)}`} + {hasRowCount + ? `${total.toLocaleString(i18n.language)}${isCapped ? "+" : ""} ${translateModelName(total)}` + : pluralModelName} - ) : undefined; + ); // Every slot has to be listed here, or its content disappears whenever nothing else occupies - // the row — including every render where the row count is hidden while loading. + // the row — including tables that hide the row count heading entirely. const renderHeaderRow = headingNode !== undefined || headingExtra !== undefined || diff --git a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx index 7763a9e92e345..0e15c6671fc40 100644 --- a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx +++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx @@ -17,11 +17,24 @@ * under the License. */ import "@testing-library/jest-dom"; -import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { delay, http, HttpResponse } from "msw"; +import { setupServer, type SetupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { handlers } from "src/mocks/handlers"; import { AppWrapper } from "src/utils/AppWrapper"; +let server: SetupServer; + +beforeAll(() => { + server = setupServer(...handlers); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + // The assets mock handler (see src/mocks/handlers/assets.ts) returns a single asset // with one consuming task, one alias and one watcher. describe("AssetsList columns", () => { @@ -41,3 +54,26 @@ describe("AssetsList columns", () => { expect(screen.getByRole("button", { name: "1 watcher" })).toBeInTheDocument(); }); }); + +describe("AssetsList filtering", () => { + it("keeps the listed assets on screen while a filter change is still loading", async () => { + render(); + + await waitFor(() => expect(screen.getByText("asset_with_dependencies")).toBeInTheDocument()); + + server.use( + http.get("/ui/assets", async () => { + await delay("infinite"); + + return HttpResponse.json({ assets: [], total_entries: 0 }); + }), + ); + + fireEvent.change(screen.getByTestId("search-dags"), { target: { value: "plain" } }); + + await waitFor(() => expect(screen.getByRole("progressbar")).toBeVisible()); + + expect(screen.getByText("asset_with_dependencies")).toBeInTheDocument(); + expect(screen.queryAllByTestId("skeleton")).toHaveLength(0); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx index 9b1dfbcb9aef7..4bafdbb7c00c8 100644 --- a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx +++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx @@ -152,15 +152,19 @@ export const AssetsList = () => { value: searchParams.get(SearchParamsKeys.GROUP_PATTERN), }); - const { data, error, isLoading } = useAssetServiceGetAssetsUi({ - ...groupArg, - lastAssetEventTimestampGte: lastAssetEventTimestampGte ?? undefined, - lastAssetEventTimestampLte: lastAssetEventTimestampLte ?? undefined, - limit: pagination.pageSize, - ...(advancedSearch.enabled ? { namePattern } : { namePrefixPattern: namePattern }), - offset: pagination.pageIndex * pagination.pageSize, - orderBy, - }); + const { data, error, isFetching, isLoading } = useAssetServiceGetAssetsUi( + { + ...groupArg, + lastAssetEventTimestampGte: lastAssetEventTimestampGte ?? undefined, + lastAssetEventTimestampLte: lastAssetEventTimestampLte ?? undefined, + limit: pagination.pageSize, + ...(advancedSearch.enabled ? { namePattern } : { namePrefixPattern: namePattern }), + offset: pagination.pageIndex * pagination.pageSize, + orderBy, + }, + undefined, + { placeholderData: (prev) => prev }, + ); const columns = createColumns(translate); const totalEntries = data?.total_entries ?? 0; @@ -200,6 +204,7 @@ export const AssetsList = () => { } initialState={tableURLState} + isFetching={isFetching} isLoading={isLoading} modelName="common:asset" onStateChange={setTableURLState} diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx index 8b897411590a5..86410475547cd 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx @@ -18,12 +18,26 @@ */ import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; +import { delay, http, HttpResponse } from "msw"; +import { setupServer, type SetupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { DAGS_LIST_DISPLAY_KEY } from "src/constants/localStorage"; +import { handlers } from "src/mocks/handlers"; import { AppWrapper } from "src/utils/AppWrapper"; -afterEach(() => localStorage.clear()); +let server: SetupServer; + +beforeAll(() => { + server = setupServer(...handlers); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterEach(() => { + server.resetHandlers(); + localStorage.clear(); +}); +afterAll(() => server.close()); describe("Dag Filters", () => { it("Filter by selected last run state", async () => { @@ -43,6 +57,32 @@ describe("Dag Filters", () => { await waitFor(() => screen.getByTestId("last_dag_run_state-filter-failed").click()); await waitFor(() => expect(screen.getByText("tutorial_taskflow_api_failed")).toBeInTheDocument()); }); + + it("keeps the listed Dags on screen while a newly added filter is still loading", async () => { + render(); + + await waitFor(() => expect(screen.getByText("tutorial_taskflow_api_failed")).toBeInTheDocument()); + + server.use( + http.get("/ui/dags", async () => { + await delay("infinite"); + + return HttpResponse.json({ dags: [], total_entries: 0 }); + }), + ); + + fireEvent.click(screen.getByTestId("add-filter-button")); + fireEvent.click(await screen.findByTestId("add-filter-last_dag_run_state")); + await waitFor(() => screen.getByTestId("last_dag_run_state-filter-success").click()); + + await waitFor(() => { + expect(screen.getByTestId("last_dag_run_state-pill")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toBeVisible(); + }); + + expect(screen.getByText("tutorial_taskflow_api_failed")).toBeInTheDocument(); + expect(screen.queryAllByTestId("skeleton")).toHaveLength(0); + }); }); describe("Dag sorting", () => { diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx index 34e7c3bdc1742..2d2a3888ad01b 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx @@ -309,7 +309,7 @@ export const DagsList = () => { pendingHitl = false; } - const { data, error, isLoading } = useDags({ + const { data, error, isFetching, isLoading } = useDags({ advancedSearch: advancedSearch.enabled, dagDisplayNamePattern: Boolean(dagDisplayNamePattern) ? dagDisplayNamePattern : undefined, dagRunsLimit, @@ -377,6 +377,7 @@ export const DagsList = () => { } headingExtra={} initialState={tableURLState} + isFetching={isFetching} isLoading={isLoading} modelName="common:dag" onDisplayToggleChange={setDisplay} diff --git a/airflow-core/src/airflow/ui/src/queries/useDags.tsx b/airflow-core/src/airflow/ui/src/queries/useDags.tsx index 566f0a1d94b2d..d6c7ca7c721f3 100644 --- a/airflow-core/src/airflow/ui/src/queries/useDags.tsx +++ b/airflow-core/src/airflow/ui/src/queries/useDags.tsx @@ -84,6 +84,8 @@ export const useDags = ({ }, undefined, { + // Filter changes swap the query key, which would otherwise drop the list to skeletons + placeholderData: (prev) => prev, refetchInterval: (query) => refetchInterval === false ? false From 867915b5bf10ab2ecc1159853d6d6a0a6cb69197 Mon Sep 17 00:00:00 2001 From: Brent Bovenzi Date: Mon, 24 Aug 2026 17:37:45 -0400 Subject: [PATCH 2/2] Delete airflow-core/newsfragments/72005.bugfix.rst --- airflow-core/newsfragments/72005.bugfix.rst | 1 - 1 file changed, 1 deletion(-) delete mode 100644 airflow-core/newsfragments/72005.bugfix.rst diff --git a/airflow-core/newsfragments/72005.bugfix.rst b/airflow-core/newsfragments/72005.bugfix.rst deleted file mode 100644 index f8587721d7fd5..0000000000000 --- a/airflow-core/newsfragments/72005.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Stop list pages shifting while a filter loads. The row count heading no longer disappears mid-load, so the search and filter controls beneath it stay put, and the Dags and Assets lists keep their current rows on screen until the filtered ones arrive.