This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 89591529a0c UI: Stop list pages jumping while a filter loads (#72005)
89591529a0c is described below
commit 89591529a0ce98568b32b7f78485f005d2daa41a
Author: rjgoyln <[email protected]>
AuthorDate: Tue Aug 25 21:53:26 2026 +0800
UI: Stop list pages jumping while a filter loads (#72005)
* 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.
* Delete airflow-core/newsfragments/72005.bugfix.rst
---------
Co-authored-by: Brent Bovenzi <[email protected]>
---
.../ui/src/components/DataTable/DataTable.test.tsx | 14 ++-----
.../ui/src/components/DataTable/DataTable.tsx | 17 +++++----
.../ui/src/pages/AssetsList/AssetsList.test.tsx | 40 +++++++++++++++++++-
.../airflow/ui/src/pages/AssetsList/AssetsList.tsx | 23 ++++++-----
.../ui/src/pages/DagsList/DagsList.test.tsx | 44 +++++++++++++++++++++-
.../src/airflow/ui/src/pages/DagsList/DagsList.tsx | 3 +-
.../src/airflow/ui/src/queries/useDags.tsx | 2 +
7 files changed, 110 insertions(+), 33 deletions(-)
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 ac8d5cf1ca5..83c2aaa1316 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(<DataTable columns={columns} data={[]} isLoading modelName="task"
total={0} />, {
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: <button type="button">slot
content</button> }],
["presentationActions", { presentationActions: <button type="button">slot
content</button> }],
@@ -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(<DataTable columns={columns} data={[]} isLoading modelName="task"
{...slot} />, {
- 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 1cd5a43dbd9..6e23b4312e8 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 = <TData,>({
(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 = <TData,>({
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 : (
<Heading py={1} size="md">
- {total === undefined
- ? pluralModelName
- : `${total.toLocaleString(i18n.language)}${isCapped ? "+" : ""}
${translateModelName(total)}`}
+ {hasRowCount
+ ? `${total.toLocaleString(i18n.language)}${isCapped ? "+" : ""}
${translateModelName(total)}`
+ : pluralModelName}
</Heading>
- ) : 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 7763a9e92e3..0e15c6671fc 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(<AppWrapper initialEntries={["/assets"]} />);
+
+ 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 9b1dfbcb9ae..4bafdbb7c00 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 = () => {
</VStack>
}
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 8b897411590..86410475547 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(<AppWrapper initialEntries={["/dags"]} />);
+
+ 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 34e7c3bdc17..2d2a3888ad0 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={<DagImportErrors iconOnly />}
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 566f0a1d94b..d6c7ca7c721 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