Skip to content

Commit

Permalink
Add ability to switch between table and card views for listing items (a…
Browse files Browse the repository at this point in the history
…pache#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
  • Loading branch information
bbovenzi authored and ellisms committed Nov 13, 2024
1 parent 8d41448 commit 8410533
Show file tree
Hide file tree
Showing 11 changed files with 706 additions and 166 deletions.
70 changes: 70 additions & 0 deletions airflow/ui/src/components/DataTable/CardList.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
46 changes: 46 additions & 0 deletions airflow/ui/src/components/DataTable/DataTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>> = [
{
Expand All @@ -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(
Expand Down Expand Up @@ -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);
});
});
143 changes: 47 additions & 96 deletions airflow/ui/src/components/DataTable/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>({
Expand All @@ -93,6 +93,10 @@ export const DataTable = <TData,>({
[onStateChange],
);

const rest = Boolean(isLoading)
? createSkeletonMock(displayMode, skeletonCount, columns)
: {};

const table = useReactTable({
columns,
data,
Expand All @@ -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>
</>
);
};
Loading

0 comments on commit 8410533

Please sign in to comment.