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 1f03df992c0 Support multi-column sort in the Dags list table (#72558)
1f03df992c0 is described below
commit 1f03df992c07a38eac8106210e4b36760be911cd
Author: Andrew Chang <[email protected]>
AuthorDate: Wed Sep 23 21:26:34 2026 +0800
Support multi-column sort in the Dags list table (#72558)
* UI: Support multi-column sort in the Dags list table
Sorting by latest run with a single criterion leaves Dags without runs in
reverse alphabetical order, because the backend applies the primary key
tiebreak in the same direction as the primary sort. The backend already
accepts several order_by values, but the Dags list only sent the first one
and the URL serializer dropped every sort but the last.
* Add tooltips for multi sort
---
.../ui/public/i18n/locales/en/components.json | 1 +
.../ui/src/components/DataTable/DataTable.test.tsx | 33 +++++++++++
.../ui/src/components/DataTable/DataTable.tsx | 4 ++
.../ui/src/components/DataTable/TableList.test.tsx | 67 ++++++++++++++++++++++
.../ui/src/components/DataTable/TableList.tsx | 43 +++++++++-----
.../src/components/DataTable/searchParams.test.ts | 39 +++++++++++++
.../ui/src/components/DataTable/searchParams.ts | 19 +++---
.../ui/src/pages/DagsList/DagsList.test.tsx | 23 ++++++++
.../src/airflow/ui/src/pages/DagsList/DagsList.tsx | 10 ++--
9 files changed, 212 insertions(+), 27 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
index b7dd72b81ed..cab7c740c60 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
@@ -136,6 +136,7 @@
"empty": "No completed task instances in this range",
"title": "Slowest task instances"
},
+ "sortMultiColumnHint": "Shift-click to sort by multiple columns",
"sortedAscending": "sorted ascending",
"sortedDescending": "sorted descending",
"sortedUnsorted": "unsorted",
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 83c2aaa1316..24e21bfcd08 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
@@ -82,6 +82,39 @@ describe("DataTable", () => {
expect(screen.getByText("Jane Doe")).toBeInTheDocument();
});
+ it.each([
+ { enableMultiSort: undefined, expected: [{ desc: false, id: "Second" }] },
+ {
+ enableMultiSort: true,
+ expected: [
+ { desc: false, id: "name" },
+ { desc: false, id: "Second" },
+ ],
+ },
+ ])(
+ "shift-click adds a secondary sort only when
enableMultiSort=$enableMultiSort",
+ ({ enableMultiSort, expected }) => {
+ const sortOnStateChange = vi.fn();
+
+ render(
+ <DataTable
+ columns={[...columns, { accessorKey: "name", header: "Second", id:
"Second" }]}
+ data={data}
+ enableMultiSort={enableMultiSort}
+ initialState={{ pagination, sorting: [{ desc: false, id: "name" }] }}
+ modelName="task"
+ onStateChange={sortOnStateChange}
+ total={2}
+ />,
+ { wrapper: ChakraWrapper },
+ );
+
+ fireEvent.click(screen.getByText("Second", { selector: "button" }), {
shiftKey: true });
+
+
expect(sortOnStateChange).toHaveBeenLastCalledWith(expect.objectContaining({
sorting: expected }));
+ },
+ );
+
it("disables previous page button on first page", () => {
render(
<DataTable
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 20a7c56501f..d88b53858a7 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
@@ -47,6 +47,8 @@ type DataTableProps<TData> = {
readonly columns: Array<MetaColumn<TData>>;
readonly data: Array<TData>;
readonly displayMode?: "card" | "table";
+ /** Lets shift-click on a column header add it as a secondary sort; the page
must send every sort to its endpoint. */
+ readonly enableMultiSort?: boolean;
readonly errorMessage?: ReactNode | string;
/**
* Controls that change *which* rows the table returns — a `SearchBar`, a
`FilterBar`, or both
@@ -110,6 +112,7 @@ export const DataTable = <TData,>({
columns,
data,
displayMode = "table",
+ enableMultiSort = false,
errorMessage,
filterActions,
headingExtra,
@@ -174,6 +177,7 @@ export const DataTable = <TData,>({
columns,
data,
enableHiding: true,
+ enableMultiSort,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
diff --git
a/airflow-core/src/airflow/ui/src/components/DataTable/TableList.test.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/TableList.test.tsx
new file mode 100644
index 00000000000..59894d33f10
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/TableList.test.tsx
@@ -0,0 +1,67 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { type ColumnDef, getCoreRowModel, useReactTable } from
"@tanstack/react-table";
+import "@testing-library/jest-dom";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { ChakraWrapper } from "src/utils/ChakraWrapper.tsx";
+
+import { TableList } from "./TableList.tsx";
+
+const columns: Array<ColumnDef<{ name: string }>> = [{ accessorKey: "name",
header: "Name" }];
+const data = [{ name: "John Doe" }];
+
+const Harness = ({ enableMultiSort }: { readonly enableMultiSort: boolean })
=> {
+ const table = useReactTable({ columns, data, enableMultiSort,
getCoreRowModel: getCoreRowModel() });
+
+ return <TableList table={table} />;
+};
+
+describe("TableList", () => {
+ it.each([
+ { enableMultiSort: false, expected: null },
+ { enableMultiSort: true, expected: "sortMultiColumnHint" },
+ ])(
+ "explains the shift hotkey in a header tooltip only when
enableMultiSort=$enableMultiSort",
+ async ({ enableMultiSort, expected }) => {
+ vi.useFakeTimers();
+
+ render(<Harness enableMultiSort={enableMultiSort} />, { wrapper:
ChakraWrapper });
+
+ const trigger = screen.getByText("Name", { selector: "button"
}).closest('[data-part="trigger"]');
+
+ try {
+ if (expected === null) {
+ expect(trigger).toBeNull();
+ } else {
+ await act(async () => {
+ fireEvent.focus(trigger as Element);
+ fireEvent.pointerEnter(trigger as Element);
+ await vi.advanceTimersByTimeAsync(500);
+ });
+
+ expect(screen.getByText(expected)).toBeInTheDocument();
+ }
+ } finally {
+ vi.useRealTimers();
+ }
+ },
+ );
+});
diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/TableList.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/TableList.tsx
index 7a65e421682..7e1504ae519 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/TableList.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/TableList.tsx
@@ -18,11 +18,13 @@
*/
import type { ReactNode } from "react";
-import { Button, Icon, Table } from "@chakra-ui/react";
+import { Button, Icon, Table, Text } from "@chakra-ui/react";
import { flexRender, type Table as TanStackTable } from
"@tanstack/react-table";
import { useTranslation } from "react-i18next";
import { TiArrowSortedDown, TiArrowSortedUp, TiArrowUnsorted } from
"react-icons/ti";
+import { Tooltip } from "src/system-components";
+
type TableListProps<TData> = {
readonly noRowsMessage?: ReactNode;
readonly table: TanStackTable<TData>;
@@ -32,6 +34,7 @@ export const TableList = <TData,>({ noRowsMessage, table }:
TableListProps<TData
"use no memo"; // remove if https://github.com/TanStack/table/issues/5567 is
resolved
const { t: translate } = useTranslation("components");
const { rows } = table.getRowModel();
+ const isMultiSorted = table.getState().sorting.length > 1;
return (
<Table.Root data-testid="table-list" size="sm" striped>
@@ -62,21 +65,31 @@ export const TableList = <TData,>({ noRowsMessage, table }:
TableListProps<TData
return (
<Table.ColumnHeader colSpan={colSpan} key={id}
paddingBlock={1} whiteSpace="nowrap">
{isPlaceholder ? undefined : (
- <Button
- _focus={{ color: "brand.500" }}
- _hover={{ color: "brand.500" }}
- aria-label={translate("sort")}
- border={0}
- color={sort === false ? undefined : "brand.500"}
- disabled={!canSort}
- gap={1}
- onClick={column.getToggleSortingHandler()}
- p={0}
- variant="plain"
+ <Tooltip
+ content={translate("sortMultiColumnHint")}
+ disabled={!column.getCanMultiSort()}
>
- {text}
- {rightIcon}
- </Button>
+ <Button
+ _focus={{ color: "brand.500" }}
+ _hover={{ color: "brand.500" }}
+ aria-label={translate("sort")}
+ border={0}
+ color={sort === false ? undefined : "brand.500"}
+ disabled={!canSort}
+ gap={1}
+ onClick={column.getToggleSortingHandler()}
+ p={0}
+ variant="plain"
+ >
+ {text}
+ {rightIcon}
+ {isMultiSorted && sort !== false ? (
+ <Text as="span"
data-testid={`sort-index-${column.id}`} fontSize="xs">
+ {column.getSortIndex() + 1}
+ </Text>
+ ) : undefined}
+ </Button>
+ </Tooltip>
)}
</Table.ColumnHeader>
);
diff --git
a/airflow-core/src/airflow/ui/src/components/DataTable/searchParams.test.ts
b/airflow-core/src/airflow/ui/src/components/DataTable/searchParams.test.ts
index a2434ee35c8..4b3d41e4104 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/searchParams.test.ts
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/searchParams.test.ts
@@ -34,6 +34,45 @@ describe("searchParams", () => {
expect(stateToSearchParams(state).toString()).toEqual("limit=20&offset=1&sort=name");
});
+
+ it("serializes every sort in order", () => {
+ const state: TableState = {
+ pagination: {
+ pageIndex: 0,
+ pageSize: 20,
+ },
+ sorting: [
+ { desc: true, id: "age" },
+ { desc: false, id: "name" },
+ ],
+ };
+
+
expect(stateToSearchParams(state).toString()).toEqual("limit=20&sort=-age&sort=name");
+ });
+
+ it("omits sort only when it matches the default sorting exactly", () => {
+ const defaultState: TableState = {
+ pagination: {
+ pageIndex: 0,
+ pageSize: 20,
+ },
+ sorting: [{ desc: true, id: "age" }],
+ };
+
+ expect(stateToSearchParams(defaultState,
defaultState).toString()).toEqual("");
+ expect(
+ stateToSearchParams(
+ {
+ ...defaultState,
+ sorting: [
+ { desc: true, id: "age" },
+ { desc: false, id: "name" },
+ ],
+ },
+ defaultState,
+ ).toString(),
+ ).toEqual("sort=-age&sort=name");
+ });
});
describe("searchParamsToState", () => {
diff --git
a/airflow-core/src/airflow/ui/src/components/DataTable/searchParams.ts
b/airflow-core/src/airflow/ui/src/components/DataTable/searchParams.ts
index 85ee6cc688f..2e152e33121 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/searchParams.ts
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/searchParams.ts
@@ -29,6 +29,14 @@ const {
SORT: SORT_PARAM,
}: SearchParamsKeysType = SearchParamsKeys;
+const isSameSorting = (sorting: SortingState, defaultSorting: SortingState |
undefined) =>
+ defaultSorting?.length === sorting.length &&
+ sorting.every((sort, index) => {
+ const defaultSort = defaultSorting[index];
+
+ return defaultSort?.id === sort.id && defaultSort.desc === sort.desc;
+ });
+
export const stateToSearchParams = (state: TableState, defaultTableState?:
TableState): URLSearchParams => {
const queryParams = new URLSearchParams(globalThis.location.search);
@@ -50,16 +58,11 @@ export const stateToSearchParams = (state: TableState,
defaultTableState?: Table
queryParams.delete(CURSOR_PARAM);
}
- if (state.sorting.length) {
+ queryParams.delete(SORT_PARAM);
+ if (!isSameSorting(state.sorting, defaultTableState?.sorting)) {
state.sorting.forEach(({ desc, id }) => {
- if (defaultTableState?.sorting.find((sort) => sort.id === id &&
sort.desc === desc)) {
- queryParams.delete(SORT_PARAM, `${desc ? "-" : ""}${id}`);
- } else {
- queryParams.set(SORT_PARAM, `${desc ? "-" : ""}${id}`);
- }
+ queryParams.append(SORT_PARAM, `${desc ? "-" : ""}${id}`);
});
- } else {
- queryParams.delete(SORT_PARAM);
}
return queryParams;
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 f8d8b0f10af..9e55cfcb008 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
@@ -134,4 +134,27 @@ describe("Dag sorting", () => {
),
);
});
+
+ it("adds a secondary sort on shift-click and sends every sort to the
request", async () => {
+ localStorage.setItem(DAGS_LIST_DISPLAY_KEY, JSON.stringify("table"));
+ const requestedOrderBy: Array<Array<string>> = [];
+
+ server.use(
+ http.get("/ui/dags", ({ request }) => {
+ requestedOrderBy.push(new
URL(request.url).searchParams.getAll("order_by"));
+
+ return HttpResponse.json({ dags: [], total_entries: 0 });
+ }),
+ );
+ render(<AppWrapper initialEntries={["/dags?sort=-last_run_run_after"]} />);
+
+ await waitFor(() =>
expect(screen.getByTestId("table-list")).toBeInTheDocument());
+ await waitFor(() =>
expect(requestedOrderBy.at(-1)).toEqual(["-last_run_run_after"]));
+
+ fireEvent.click(screen.getByText("dagId").closest("button") as
HTMLButtonElement, { shiftKey: true });
+
+ await waitFor(() =>
expect(requestedOrderBy.at(-1)).toEqual(["-last_run_run_after",
"dag_display_name"]));
+
expect(screen.getByTestId("sort-index-last_run_run_after")).toHaveTextContent("1");
+
expect(screen.getByTestId("sort-index-dag_display_name")).toHaveTextContent("2");
+ });
});
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 2d2d9568229..8d505c47bcc 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -283,8 +283,9 @@ export const DagsList = () => {
const dagDisplayNamePattern = searchParams.get(NAME_PATTERN) ?? "";
const advancedSearch = useAdvancedSearch("dags");
- const [sort] = sorting;
- const orderBy = sort ? `${sort.desc ? "-" : ""}${sort.id}` :
"dag_display_name";
+ const orderBy = sorting.length
+ ? sorting.map((sort) => `${sort.desc ? "-" : ""}${sort.id}`)
+ : ["dag_display_name"];
const handleSearchChange = (value: string) => {
setTableURLState({
@@ -333,7 +334,7 @@ export const DagsList = () => {
lastDagRunState,
limit: pagination.pageSize,
offset: pagination.pageIndex * pagination.pageSize,
- orderBy: [orderBy],
+ orderBy,
owners,
paused,
pendingHitl,
@@ -379,6 +380,7 @@ export const DagsList = () => {
columns={columns}
data={data?.dags ?? []}
displayMode={display}
+ enableMultiSort
errorMessage={<ErrorAlert error={error} />}
filterActions={
<VStack alignItems="flex-start" gap={2} w="100%">
@@ -400,7 +402,7 @@ export const DagsList = () => {
onStateChange={setTableURLState}
presentationActions={
display === "card" ? (
- <SortSelect handleSortChange={handleSortChange}
orderBy={orderBy} />
+ <SortSelect handleSortChange={handleSortChange}
orderBy={orderBy[0]} />
) : undefined
}
showDisplayToggle