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 772fad4de6 Add ability to switch between table and card views for
listing items (#42711)
772fad4de6 is described below
commit 772fad4de692e9f07a8a3f3cc1e016b0cb1e9f92
Author: Brent Bovenzi <[email protected]>
AuthorDate: Tue Oct 8 12:08:13 2024 +0200
Add ability to switch between table and card views for listing items
(#42711)
* Add ability to switch between table and card views for listing items
* add test for DagCard and loading states
* Add tests for loading states
* PR feedback
* use semantic tokens for reused colors
* Remove eslint-ignore lost in rebase
---
airflow/ui/src/components/DataTable/CardList.tsx | 70 ++++++++++
.../ui/src/components/DataTable/DataTable.test.tsx | 46 +++++++
airflow/ui/src/components/DataTable/DataTable.tsx | 143 +++++++--------------
airflow/ui/src/components/DataTable/TableList.tsx | 125 ++++++++++++++++++
.../components/DataTable/ToggleTableDisplay.tsx | 54 ++++++++
.../DataTable/{types.ts => skeleton.tsx} | 35 ++++-
airflow/ui/src/components/DataTable/types.ts | 23 +++-
airflow/ui/src/pages/DagsList/DagCard.test.tsx | 87 +++++++++++++
airflow/ui/src/pages/DagsList/DagCard.tsx | 118 +++++++++++++++++
airflow/ui/src/pages/DagsList/DagsList.tsx | 101 +++++++++------
airflow/ui/src/theme.ts | 54 ++++----
11 files changed, 686 insertions(+), 170 deletions(-)
diff --git a/airflow/ui/src/components/DataTable/CardList.tsx
b/airflow/ui/src/components/DataTable/CardList.tsx
new file mode 100644
index 0000000000..ddebff81b2
--- /dev/null
+++ b/airflow/ui/src/components/DataTable/CardList.tsx
@@ -0,0 +1,70 @@
+/*!
+ * 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 { Box, SimpleGrid, Skeleton } from "@chakra-ui/react";
+import {
+ type CoreRow,
+ flexRender,
+ type Table as TanStackTable,
+} from "@tanstack/react-table";
+import type { SyntheticEvent } from "react";
+
+import type { CardDef } from "./types";
+
+type DataTableProps<TData> = {
+ readonly cardDef: CardDef<TData>;
+ readonly isLoading?: boolean;
+ readonly onRowClick?: (e: SyntheticEvent, row: CoreRow<TData>) => void;
+ readonly table: TanStackTable<TData>;
+};
+
+export const CardList = <TData,>({
+ cardDef,
+ isLoading,
+ onRowClick,
+ table,
+}: DataTableProps<TData>) => {
+ const defaultGridProps = { column: { base: 1 }, spacing: 2 };
+
+ return (
+ <Box overflow="auto" width="100%">
+ <SimpleGrid {...{ ...defaultGridProps, ...cardDef.gridProps }}>
+ {table.getRowModel().rows.map((row) => (
+ <Box
+ _hover={onRowClick ? { cursor: "pointer" } : undefined}
+ key={row.id}
+ onClick={onRowClick ? (event) => onRowClick(event, row) :
undefined}
+ title={onRowClick ? "View details" : undefined}
+ >
+ {Boolean(isLoading) &&
+ (cardDef.meta?.customSkeleton ?? (
+ <Skeleton
+ data-testid="skeleton"
+ display="inline-block"
+ height={80}
+ width="100%"
+ />
+ ))}
+ {!Boolean(isLoading) &&
+ flexRender(cardDef.card, { row: row.original })}
+ </Box>
+ ))}
+ </SimpleGrid>
+ </Box>
+ );
+};
diff --git a/airflow/ui/src/components/DataTable/DataTable.test.tsx
b/airflow/ui/src/components/DataTable/DataTable.test.tsx
index c83f15f8f3..028ba27ce2 100644
--- a/airflow/ui/src/components/DataTable/DataTable.test.tsx
+++ b/airflow/ui/src/components/DataTable/DataTable.test.tsx
@@ -16,12 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { Text } from "@chakra-ui/react";
import type { ColumnDef, PaginationState } from "@tanstack/react-table";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DataTable } from "./DataTable.tsx";
+import type { CardDef } from "./types.ts";
const columns: Array<ColumnDef<{ name: string }>> = [
{
@@ -36,6 +38,10 @@ const data = [{ name: "John Doe" }, { name: "Jane Doe" }];
const pagination: PaginationState = { pageIndex: 0, pageSize: 1 };
const onStateChange = vi.fn();
+const cardDef: CardDef<{ name: string }> = {
+ card: ({ row }) => <Text>My name is {row.name}.</Text>,
+};
+
describe("DataTable", () => {
it("renders table with data", () => {
render(
@@ -84,4 +90,44 @@ describe("DataTable", () => {
expect(screen.getByText(">>")).toBeDisabled();
expect(screen.getByText(">")).toBeDisabled();
});
+
+ it("when isLoading renders skeleton columns", () => {
+ render(<DataTable columns={columns} data={data} isLoading />);
+
+ expect(screen.getAllByTestId("skeleton")).toHaveLength(10);
+ });
+
+ it("still displays table if mode is card but there is no cardDef", () => {
+ render(<DataTable columns={columns} data={data} displayMode="card" />);
+
+ expect(screen.getByText("Name")).toBeInTheDocument();
+ });
+
+ it("displays cards if mode is card and there is cardDef", () => {
+ render(
+ <DataTable
+ cardDef={cardDef}
+ columns={columns}
+ data={data}
+ displayMode="card"
+ />,
+ );
+
+ expect(screen.getByText("My name is John Doe.")).toBeInTheDocument();
+ });
+
+ it("displays skeleton for loading card list", () => {
+ render(
+ <DataTable
+ cardDef={cardDef}
+ columns={columns}
+ data={data}
+ displayMode="card"
+ isLoading
+ skeletonCount={5}
+ />,
+ );
+
+ expect(screen.getAllByTestId("skeleton")).toHaveLength(5);
+ });
});
diff --git a/airflow/ui/src/components/DataTable/DataTable.tsx
b/airflow/ui/src/components/DataTable/DataTable.tsx
index 48b9346602..7f4c3cf083 100644
--- a/airflow/ui/src/components/DataTable/DataTable.tsx
+++ b/airflow/ui/src/components/DataTable/DataTable.tsx
@@ -16,60 +16,60 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { Progress, Text } from "@chakra-ui/react";
import {
- Table as ChakraTable,
- TableContainer,
- Tbody,
- Td,
- Th,
- Thead,
- Tr,
- useColorModeValue,
-} from "@chakra-ui/react";
-import {
- flexRender,
getCoreRowModel,
getExpandedRowModel,
getPaginationRowModel,
useReactTable,
- type ColumnDef,
type OnChangeFn,
type TableState as ReactTableState,
type Row,
type Table as TanStackTable,
type Updater,
} from "@tanstack/react-table";
-import React, { Fragment, useCallback, useRef } from "react";
-import {
- TiArrowSortedDown,
- TiArrowSortedUp,
- TiArrowUnsorted,
-} from "react-icons/ti";
+import React, { type ReactNode, useCallback, useRef } from "react";
+import { CardList } from "./CardList";
+import { TableList } from "./TableList";
import { TablePaginator } from "./TablePaginator";
-import type { TableState } from "./types";
+import { createSkeletonMock } from "./skeleton";
+import type { CardDef, MetaColumn, TableState } from "./types";
type DataTableProps<TData> = {
- readonly columns: Array<ColumnDef<TData>>;
+ readonly cardDef?: CardDef<TData>;
+ readonly columns: Array<MetaColumn<TData>>;
readonly data: Array<TData>;
+ readonly displayMode?: "card" | "table";
readonly getRowCanExpand?: (row: Row<TData>) => boolean;
readonly initialState?: TableState;
+ readonly isFetching?: boolean;
+ readonly isLoading?: boolean;
+ readonly modelName?: string;
+ readonly noRowsMessage?: ReactNode;
readonly onStateChange?: (state: TableState) => void;
readonly renderSubComponent?: (props: {
row: Row<TData>;
}) => React.ReactElement;
+ readonly skeletonCount?: number;
readonly total?: number;
};
const defaultGetRowCanExpand = () => false;
export const DataTable = <TData,>({
+ cardDef,
columns,
data,
+ displayMode = "table",
getRowCanExpand = defaultGetRowCanExpand,
initialState,
+ isFetching,
+ isLoading,
+ modelName,
+ noRowsMessage,
onStateChange,
- renderSubComponent,
+ skeletonCount = 10,
total = 0,
}: DataTableProps<TData>) => {
const ref = useRef<{ tableRef: TanStackTable<TData> | undefined }>({
@@ -93,6 +93,10 @@ export const DataTable = <TData,>({
[onStateChange],
);
+ const rest = Boolean(isLoading)
+ ? createSkeletonMock(displayMode, skeletonCount, columns)
+ : {};
+
const table = useReactTable({
columns,
data,
@@ -105,87 +109,34 @@ export const DataTable = <TData,>({
onStateChange: handleStateChange,
rowCount: total,
state: initialState,
+ ...rest,
});
ref.current.tableRef = table;
- const theadBg = useColorModeValue("white", "gray.800");
+ const { rows } = table.getRowModel();
- return (
- <TableContainer maxH="calc(100vh - 10rem)" overflowY="auto">
- <ChakraTable colorScheme="blue">
- <Thead bg={theadBg} position="sticky" top={0} zIndex={1}>
- {table.getHeaderGroups().map((headerGroup) => (
- <Tr key={headerGroup.id}>
- {headerGroup.headers.map(
- ({ colSpan, column, getContext, id, isPlaceholder }) => {
- const sort = column.getIsSorted();
- const canSort = column.getCanSort();
+ const display = displayMode === "card" && Boolean(cardDef) ? "card" :
"table";
- return (
- <Th
- colSpan={colSpan}
- cursor={column.getCanSort() ? "pointer" : undefined}
- key={id}
- onClick={column.getToggleSortingHandler()}
- whiteSpace="nowrap"
- >
- {isPlaceholder ? undefined : (
- <>{flexRender(column.columnDef.header,
getContext())}</>
- )}
- {canSort && sort === false ? (
- <TiArrowUnsorted
- aria-label="unsorted"
- size="1em"
- style={{ display: "inline" }}
- />
- ) : undefined}
- {canSort && sort !== false ? (
- sort === "desc" ? (
- <TiArrowSortedDown
- aria-label="sorted descending"
- size="1em"
- style={{ display: "inline" }}
- />
- ) : (
- <TiArrowSortedUp
- aria-label="sorted ascending"
- size="1em"
- style={{ display: "inline" }}
- />
- )
- ) : undefined}
- </Th>
- );
- },
- )}
- </Tr>
- ))}
- </Thead>
- <Tbody>
- {table.getRowModel().rows.map((row) => (
- <Fragment key={row.id}>
- <Tr>
- {/* first row is a normal row */}
- {row.getVisibleCells().map((cell) => (
- <Td key={cell.id}>
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
- </Td>
- ))}
- </Tr>
- {row.getIsExpanded() && (
- <Tr>
- {/* 2nd row is a custom 1 cell row */}
- <Td colSpan={row.getVisibleCells().length}>
- {renderSubComponent?.({ row })}
- </Td>
- </Tr>
- )}
- </Fragment>
- ))}
- </Tbody>
- </ChakraTable>
+ return (
+ <>
+ <Progress
+ isIndeterminate
+ size="xs"
+ visibility={
+ Boolean(isFetching) && !Boolean(isLoading) ? "visible" : "hidden"
+ }
+ />
+ {!Boolean(isLoading) && !rows.length && (
+ <Text fontSize="small">
+ {noRowsMessage ?? `No ${modelName}s found.`}
+ </Text>
+ )}
+ {display === "table" && <TableList table={table} />}
+ {display === "card" && cardDef !== undefined && (
+ <CardList cardDef={cardDef} isLoading={isLoading} table={table} />
+ )}
<TablePaginator table={table} />
- </TableContainer>
+ </>
);
};
diff --git a/airflow/ui/src/components/DataTable/TableList.tsx
b/airflow/ui/src/components/DataTable/TableList.tsx
new file mode 100644
index 0000000000..97b7fd6aed
--- /dev/null
+++ b/airflow/ui/src/components/DataTable/TableList.tsx
@@ -0,0 +1,125 @@
+/*!
+ * 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 {
+ Table as ChakraTable,
+ TableContainer,
+ Tbody,
+ Td,
+ Th,
+ Thead,
+ Tr,
+} from "@chakra-ui/react";
+import {
+ flexRender,
+ type Row,
+ type Table as TanStackTable,
+} from "@tanstack/react-table";
+import React, { Fragment } from "react";
+import {
+ TiArrowSortedDown,
+ TiArrowSortedUp,
+ TiArrowUnsorted,
+} from "react-icons/ti";
+
+type DataTableProps<TData> = {
+ readonly renderSubComponent?: (props: {
+ row: Row<TData>;
+ }) => React.ReactElement;
+ readonly table: TanStackTable<TData>;
+};
+
+export const TableList = <TData,>({
+ renderSubComponent,
+ table,
+}: DataTableProps<TData>) => (
+ <TableContainer maxH="calc(100vh - 10rem)" overflowY="auto">
+ <ChakraTable colorScheme="blue">
+ <Thead bg="chakra-body-bg" position="sticky" top={0} zIndex={1}>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <Tr key={headerGroup.id}>
+ {headerGroup.headers.map(
+ ({ colSpan, column, getContext, id, isPlaceholder }) => {
+ const sort = column.getIsSorted();
+ const canSort = column.getCanSort();
+
+ return (
+ <Th
+ colSpan={colSpan}
+ cursor={column.getCanSort() ? "pointer" : undefined}
+ key={id}
+ onClick={column.getToggleSortingHandler()}
+ whiteSpace="nowrap"
+ >
+ {isPlaceholder ? undefined : (
+ <>{flexRender(column.columnDef.header, getContext())}</>
+ )}
+ {canSort && sort === false ? (
+ <TiArrowUnsorted
+ aria-label="unsorted"
+ size="1em"
+ style={{ display: "inline" }}
+ />
+ ) : undefined}
+ {canSort && sort !== false ? (
+ sort === "desc" ? (
+ <TiArrowSortedDown
+ aria-label="sorted descending"
+ size="1em"
+ style={{ display: "inline" }}
+ />
+ ) : (
+ <TiArrowSortedUp
+ aria-label="sorted ascending"
+ size="1em"
+ style={{ display: "inline" }}
+ />
+ )
+ ) : undefined}
+ </Th>
+ );
+ },
+ )}
+ </Tr>
+ ))}
+ </Thead>
+ <Tbody>
+ {table.getRowModel().rows.map((row) => (
+ <Fragment key={row.id}>
+ <Tr>
+ {/* first row is a normal row */}
+ {row.getVisibleCells().map((cell) => (
+ <Td key={cell.id}>
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </Td>
+ ))}
+ </Tr>
+ {row.getIsExpanded() && (
+ <Tr>
+ {/* 2nd row is a custom 1 cell row */}
+ <Td colSpan={row.getVisibleCells().length}>
+ {renderSubComponent?.({ row })}
+ </Td>
+ </Tr>
+ )}
+ </Fragment>
+ ))}
+ </Tbody>
+ </ChakraTable>
+ </TableContainer>
+);
diff --git a/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx
b/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx
new file mode 100644
index 0000000000..489fbdc1ce
--- /dev/null
+++ b/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx
@@ -0,0 +1,54 @@
+/*!
+ * 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 { HStack, IconButton } from "@chakra-ui/react";
+import { FiAlignJustify, FiGrid } from "react-icons/fi";
+
+type Display = "card" | "table";
+
+type Props = {
+ readonly display: Display;
+ readonly setDisplay: (display: Display) => void;
+};
+
+export const ToggleTableDisplay = ({ display, setDisplay }: Props) => (
+ <HStack pb={2} spacing={1}>
+ <IconButton
+ aria-label="Show card view"
+ colorScheme="blue"
+ height={8}
+ icon={<FiGrid />}
+ isActive={display === "card"}
+ minWidth={8}
+ onClick={() => setDisplay("card")}
+ variant="outline"
+ width={8}
+ />
+ <IconButton
+ aria-label="Show table view"
+ colorScheme="blue"
+ height={8}
+ icon={<FiAlignJustify />}
+ isActive={display === "table"}
+ minWidth={8}
+ onClick={() => setDisplay("table")}
+ variant="outline"
+ width={8}
+ />
+ </HStack>
+);
diff --git a/airflow/ui/src/components/DataTable/types.ts
b/airflow/ui/src/components/DataTable/skeleton.tsx
similarity index 51%
copy from airflow/ui/src/components/DataTable/types.ts
copy to airflow/ui/src/components/DataTable/skeleton.tsx
index febf9acdb0..e237e4ad10 100644
--- a/airflow/ui/src/components/DataTable/types.ts
+++ b/airflow/ui/src/components/DataTable/skeleton.tsx
@@ -16,9 +16,36 @@
* specific language governing permissions and limitations
* under the License.
*/
-import type { PaginationState, SortingState } from "@tanstack/react-table";
+import { Skeleton } from "@chakra-ui/react";
-export type TableState = {
- pagination: PaginationState;
- sorting: SortingState;
+import type { MetaColumn } from "./types";
+
+export const createSkeletonMock = <TData,>(
+ mode: "card" | "table",
+ skeletonCount: number,
+ columnDefs: Array<MetaColumn<TData>>,
+) => {
+ const colDefs = columnDefs.map((colDef) => ({
+ ...colDef,
+ cell: () => {
+ if (mode === "table") {
+ return (
+ colDef.meta?.customSkeleton ?? (
+ <Skeleton
+ data-testid="skeleton"
+ display="inline-block"
+ height="16px"
+ width={colDef.meta?.skeletonWidth ?? 200}
+ />
+ )
+ );
+ }
+
+ return undefined;
+ },
+ }));
+
+ const data = [...Array<TData>(skeletonCount)].map(() => ({}));
+
+ return { columns: colDefs, data };
};
diff --git a/airflow/ui/src/components/DataTable/types.ts
b/airflow/ui/src/components/DataTable/types.ts
index febf9acdb0..4741b61ff6 100644
--- a/airflow/ui/src/components/DataTable/types.ts
+++ b/airflow/ui/src/components/DataTable/types.ts
@@ -16,9 +16,30 @@
* specific language governing permissions and limitations
* under the License.
*/
-import type { PaginationState, SortingState } from "@tanstack/react-table";
+import type { SimpleGridProps } from "@chakra-ui/react";
+import type {
+ ColumnDef,
+ PaginationState,
+ SortingState,
+} from "@tanstack/react-table";
+import type { ReactNode } from "react";
export type TableState = {
pagination: PaginationState;
sorting: SortingState;
};
+
+export type CardDef<TData> = {
+ card: (props: { row: TData }) => ReactNode;
+ gridProps?: SimpleGridProps;
+ meta?: {
+ customSkeleton?: JSX.Element;
+ };
+};
+
+export type MetaColumn<TData> = {
+ meta?: {
+ customSkeleton?: ReactNode;
+ skeletonWidth?: number;
+ } & ColumnDef<TData>["meta"];
+} & ColumnDef<TData>;
diff --git a/airflow/ui/src/pages/DagsList/DagCard.test.tsx
b/airflow/ui/src/pages/DagsList/DagCard.test.tsx
new file mode 100644
index 0000000000..3ae6e4fcee
--- /dev/null
+++ b/airflow/ui/src/pages/DagsList/DagCard.test.tsx
@@ -0,0 +1,87 @@
+/* eslint-disable unicorn/no-null */
+
+/*!
+ * 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 { render, screen } from "@testing-library/react";
+import type {
+ DAGResponse,
+ DagTagPydantic,
+} from "openapi-gen/requests/types.gen";
+import { afterEach, describe, it, vi, expect } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { DagCard } from "./DagCard";
+
+const mockDag = {
+ dag_display_name: "nested_groups",
+ dag_id: "nested_groups",
+ default_view: "grid",
+ description: null,
+ file_token:
+
"Ii9maWxlcy9kYWdzL25lc3RlZF90YXNrX2dyb3Vwcy5weSI.G3EkdxmDUDQsVb7AIZww1TSGlFE",
+ fileloc: "/files/dags/nested_task_groups.py",
+ has_import_errors: false,
+ has_task_concurrency_limits: false,
+ is_active: true,
+ is_paused: false,
+ last_expired: null,
+ last_parsed_time: "2024-08-22T13:50:10.372238+00:00",
+ last_pickled: null,
+ max_active_runs: 16,
+ max_active_tasks: 16,
+ max_consecutive_failed_dag_runs: 0,
+ next_dagrun: "2024-08-22T00:00:00+00:00",
+ next_dagrun_create_after: "2024-08-23T00:00:00+00:00",
+ next_dagrun_data_interval_end: "2024-08-23T00:00:00+00:00",
+ next_dagrun_data_interval_start: "2024-08-22T00:00:00+00:00",
+ owners: ["airflow"],
+ pickle_id: null,
+ scheduler_lock: null,
+ tags: [],
+ timetable_description: "",
+ timetable_summary: "",
+} satisfies DAGResponse;
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("DagCard", () => {
+ it("DagCard should render without tags", () => {
+ render(<DagCard dag={mockDag} />, { wrapper: Wrapper });
+ expect(screen.getByText(mockDag.dag_display_name)).toBeInTheDocument();
+ expect(screen.queryByTestId("dag-tag")).toBeNull();
+ });
+
+ it("DagCard should show +X more text if there are more than 3 tags", () => {
+ const tags = [
+ { dag_id: "id", name: "tag1" },
+ { dag_id: "id", name: "tag2" },
+ { dag_id: "id", name: "tag3" },
+ { dag_id: "id", name: "tag4" },
+ ] satisfies Array<DagTagPydantic>;
+
+ const expandedMockDag = { ...mockDag, tags } satisfies DAGResponse;
+
+ render(<DagCard dag={expandedMockDag} />, { wrapper: Wrapper });
+ expect(screen.getByTestId("dag-tag")).toBeInTheDocument();
+ expect(screen.getByText("+1 more")).toBeInTheDocument();
+ });
+});
diff --git a/airflow/ui/src/pages/DagsList/DagCard.tsx
b/airflow/ui/src/pages/DagsList/DagCard.tsx
new file mode 100644
index 0000000000..d555abbc0c
--- /dev/null
+++ b/airflow/ui/src/pages/DagsList/DagCard.tsx
@@ -0,0 +1,118 @@
+/*!
+ * 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 {
+ Badge,
+ Box,
+ Flex,
+ HStack,
+ Heading,
+ SimpleGrid,
+ Text,
+ Tooltip,
+ useColorModeValue,
+ VStack,
+} from "@chakra-ui/react";
+import { FiCalendar, FiTag } from "react-icons/fi";
+
+import type { DAGResponse } from "openapi/requests/types.gen";
+import { TogglePause } from "src/components/TogglePause";
+
+type Props = {
+ readonly dag: DAGResponse;
+};
+
+const MAX_TAGS = 3;
+
+export const DagCard = ({ dag }: Props) => {
+ const cardBorder = useColorModeValue("gray.100", "gray.700");
+ const tooltipBg = useColorModeValue("gray.200", "gray.700");
+
+ return (
+ <Box
+ borderColor={cardBorder}
+ borderRadius={8}
+ borderWidth={1}
+ overflow="hidden"
+ >
+ <Flex
+ alignItems="center"
+ bg="subtle-bg"
+ justifyContent="space-between"
+ px={3}
+ py={2}
+ >
+ <HStack>
+ <Tooltip hasArrow label={dag.description}>
+ <Heading color="subtle-text" fontSize="md">
+ {dag.dag_display_name}
+ </Heading>
+ </Tooltip>
+ {dag.tags.length ? (
+ <HStack spacing={1}>
+ <FiTag data-testid="dag-tag" />
+ {dag.tags.slice(0, MAX_TAGS).map((tag) => (
+ <Badge key={tag.name}>{tag.name}</Badge>
+ ))}
+ {dag.tags.length > MAX_TAGS && (
+ <Tooltip
+ bg={tooltipBg}
+ hasArrow
+ label={
+ <VStack p={1} spacing={1}>
+ {dag.tags.slice(MAX_TAGS).map((tag) => (
+ <Badge key={tag.name}>{tag.name}</Badge>
+ ))}
+ </VStack>
+ }
+ >
+ <Badge>+{dag.tags.length - MAX_TAGS} more</Badge>
+ </Tooltip>
+ )}
+ </HStack>
+ ) : undefined}
+ </HStack>
+ <HStack>
+ <TogglePause dagId={dag.dag_id} isPaused={dag.is_paused} />
+ </HStack>
+ </Flex>
+ <SimpleGrid columns={4} height={20} px={3} py={2} spacing={4}>
+ <div />
+ <VStack align="flex-start" spacing={1}>
+ <Heading color="gray.500" fontSize="xs">
+ Next Run
+ </Heading>
+ {Boolean(dag.next_dagrun) ? (
+ <Text fontSize="sm">{dag.next_dagrun}</Text>
+ ) : undefined}
+ {Boolean(dag.timetable_summary) ? (
+ <Tooltip hasArrow label={dag.timetable_description}>
+ <Text fontSize="sm">
+ {" "}
+ <FiCalendar style={{ display: "inline" }} />{" "}
+ {dag.timetable_summary}
+ </Text>
+ </Tooltip>
+ ) : undefined}
+ </VStack>
+ <div />
+ <div />
+ </SimpleGrid>
+ </Box>
+ );
+};
diff --git a/airflow/ui/src/pages/DagsList/DagsList.tsx
b/airflow/ui/src/pages/DagsList/DagsList.tsx
index 90981a0747..7b87e8d253 100644
--- a/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -21,21 +21,24 @@ import {
Heading,
HStack,
Select,
- Spinner,
+ Skeleton,
VStack,
} from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
-import { type ChangeEventHandler, useCallback } from "react";
+import { type ChangeEventHandler, useCallback, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useDagServiceGetDags } from "openapi/queries";
import type { DAGResponse } from "openapi/requests/types.gen";
import { DataTable } from "src/components/DataTable";
+import { ToggleTableDisplay } from
"src/components/DataTable/ToggleTableDisplay";
+import type { CardDef } from "src/components/DataTable/types";
import { useTableURLState } from "src/components/DataTable/useTableUrlState";
import { SearchBar } from "src/components/SearchBar";
import { TogglePause } from "src/components/TogglePause";
import { pluralize } from "src/utils/pluralize";
+import { DagCard } from "./DagCard";
import { DagsFilters } from "./DagsFilters";
const columns: Array<ColumnDef<DAGResponse>> = [
@@ -49,6 +52,9 @@ const columns: Array<ColumnDef<DAGResponse>> = [
),
enableSorting: false,
header: "",
+ meta: {
+ skeletonWidth: 10,
+ },
},
{
accessorKey: "dag_id",
@@ -83,10 +89,18 @@ const columns: Array<ColumnDef<DAGResponse>> = [
},
];
+const cardDef: CardDef<DAGResponse> = {
+ card: ({ row }) => <DagCard dag={row} />,
+ meta: {
+ customSkeleton: <Skeleton height="120px" width="100%" />,
+ },
+};
+
const PAUSED_PARAM = "paused";
-export const DagsList = ({ cardView = false }) => {
+export const DagsList = () => {
const [searchParams] = useSearchParams();
+ const [display, setDisplay] = useState<"card" | "table">("card");
const showPaused = searchParams.get(PAUSED_PARAM);
@@ -97,7 +111,7 @@ export const DagsList = ({ cardView = false }) => {
const [sort] = sorting;
const orderBy = sort ? `${sort.desc ? "-" : ""}${sort.id}` : undefined;
- const { data, isLoading } = useDagServiceGetDags({
+ const { data, isFetching, isLoading } = useDagServiceGetDags({
limit: pagination.pageSize,
offset: pagination.pageIndex * pagination.pageSize,
onlyActive: true,
@@ -119,44 +133,47 @@ export const DagsList = ({ cardView = false }) => {
return (
<>
- {isLoading ? <Spinner /> : undefined}
- {!isLoading && Boolean(data?.dags) && (
- <>
- <VStack alignItems="none">
- <SearchBar
- buttonProps={{ isDisabled: true }}
- inputProps={{ isDisabled: true }}
- />
- <DagsFilters />
- <HStack justifyContent="space-between">
- <Heading size="md">
- {pluralize("DAG", data?.total_entries)}
- </Heading>
- {cardView ? (
- <Select
- onChange={handleSortChange}
- placeholder="Sort by…"
- value={orderBy}
- variant="flushed"
- width="200px"
- >
- <option value="dag_id">Sort by DAG ID (A-Z)</option>
- <option value="-dag_id">Sort by DAG ID (Z-A)</option>
- </Select>
- ) : (
- false
- )}
- </HStack>
- </VStack>
- <DataTable
- columns={columns}
- data={data?.dags ?? []}
- initialState={tableURLState}
- onStateChange={setTableURLState}
- total={data?.total_entries}
- />
- </>
- )}
+ <VStack alignItems="none">
+ <SearchBar
+ buttonProps={{ isDisabled: true }}
+ inputProps={{ isDisabled: true }}
+ />
+ <DagsFilters />
+ <HStack justifyContent="space-between">
+ <Heading py={3} size="md">
+ {pluralize("DAG", data?.total_entries)}
+ </Heading>
+ {display === "card" ? (
+ <Select
+ data-testid="sort-by-select"
+ onChange={handleSortChange}
+ placeholder="Sort by…"
+ value={orderBy}
+ variant="flushed"
+ width="200px"
+ >
+ <option value="dag_id">Sort by DAG ID (A-Z)</option>
+ <option value="-dag_id">Sort by DAG ID (Z-A)</option>
+ </Select>
+ ) : (
+ false
+ )}
+ </HStack>
+ </VStack>
+ <ToggleTableDisplay display={display} setDisplay={setDisplay} />
+ <DataTable
+ cardDef={cardDef}
+ columns={columns}
+ data={data?.dags ?? []}
+ displayMode={display}
+ initialState={tableURLState}
+ isFetching={isFetching}
+ isLoading={isLoading}
+ modelName="DAG"
+ onStateChange={setTableURLState}
+ skeletonCount={display === "card" ? 5 : undefined}
+ total={data?.total_entries}
+ />
</>
);
};
diff --git a/airflow/ui/src/theme.ts b/airflow/ui/src/theme.ts
index e172bf7650..06a3b10cc7 100644
--- a/airflow/ui/src/theme.ts
+++ b/airflow/ui/src/theme.ts
@@ -24,39 +24,33 @@ import { createMultiStyleConfigHelpers, extendTheme } from
"@chakra-ui/react";
const { defineMultiStyleConfig, definePartsStyle } =
createMultiStyleConfigHelpers(tableAnatomy.keys);
-const baseStyle = definePartsStyle((props) => {
- const { colorMode, colorScheme } = props;
-
- return {
- tbody: {
- tr: {
- "&:nth-of-type(even)": {
- "th, td": {
- borderBottomWidth: "0px",
- },
+const baseStyle = definePartsStyle(() => ({
+ tbody: {
+ tr: {
+ "&:nth-of-type(even)": {
+ "th, td": {
+ borderBottomWidth: "0px",
+ },
+ },
+ "&:nth-of-type(odd)": {
+ td: {
+ background: "subtle-bg",
},
- "&:nth-of-type(odd)": {
- td: {
- background:
- colorMode === "light" ? `${colorScheme}.50` : `gray.900`,
- },
- "th, td": {
- borderBottomWidth: "0px",
- borderColor:
- colorMode === "light" ? `${colorScheme}.50` : `gray.900`,
- },
+ "th, td": {
+ borderBottomWidth: "0px",
+ borderColor: "subtle-bg",
},
},
},
- thead: {
- tr: {
- th: {
- borderBottomWidth: 0,
- },
+ },
+ thead: {
+ tr: {
+ th: {
+ borderBottomWidth: 0,
},
},
- };
-});
+ },
+}));
export const tableTheme = defineMultiStyleConfig({ baseStyle });
@@ -72,6 +66,12 @@ const theme = extendTheme({
config: {
useSystemColorMode: true,
},
+ semanticTokens: {
+ colors: {
+ "subtle-bg": { _dark: "gray.900", _light: "blue.50" },
+ "subtle-text": { _dark: "blue.500", _light: "blue.600" },
+ },
+ },
styles: {
global: {
"*, *::before, &::after": {