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": {


Reply via email to