Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 275 additions & 0 deletions src/ui/components/ResponsiveTable/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import {
Table,
Card,
Text,
SimpleGrid,
Stack,
UnstyledButton,
Center,
Box,
type MantineSpacing,
} from "@mantine/core";
import {
IconChevronUp,
IconChevronDown,
IconSelector,
} from "@tabler/icons-react";
import React, { useState } from "react";

// Types
export interface Column<T> {
key: string;
label: string;
sortable?: boolean;
render: (item: T) => React.ReactNode;
mobileLabel?: string; // Optional different label for mobile
hideMobileLabel?: boolean; // Hide label in mobile card view
mobileLabelStyle?: React.CSSProperties; // Custom styles for mobile label
cardColumn?: number; // Which column this field should appear in (1 or 2), defaults to auto-flow
isPrimaryColumn?: boolean; // Bold and emphasize this column in mobile cards
}

export interface ResponsiveTableProps<T> {
data: T[];
columns: Column<T>[];
keyExtractor: (item: T) => string;
onSort?: (key: string) => void;
sortBy?: string | null;
sortReversed?: boolean;
testIdPrefix?: string;
onRowClick?: (item: T) => void;
mobileBreakpoint?: number; // px value, defaults to 768
mobileLabelStyle?: React.CSSProperties; // Default style for all mobile labels
padding?: MantineSpacing;
mobileColumns?:
| number
| { base?: number; xs?: number; sm?: number; md?: number }; // Grid columns for mobile cards
cardColumns?: number | { base?: number; xs?: number; sm?: number }; // Columns inside each card
testId?: string; // Table data-testId
}

interface ThProps {
children: React.ReactNode;
reversed: boolean;
sorted: boolean;
onSort: () => void;
}

function Th({ children, reversed, sorted, onSort }: ThProps) {
const Icon = sorted
? reversed
? IconChevronUp
: IconChevronDown
: IconSelector;

return (
<Table.Th>
<UnstyledButton
onClick={onSort}
style={{ display: "flex", alignItems: "center", gap: "4px" }}
>
<Text fw={500} size="sm">
{children}
</Text>
<Center>
<Icon size={14} stroke={1.5} />
</Center>
</UnstyledButton>
</Table.Th>
);
}

export function ResponsiveTable<T>({
data,
columns,
keyExtractor,
onSort,
sortBy = null,
sortReversed = false,
testIdPrefix,
onRowClick,
mobileBreakpoint = 768,
mobileLabelStyle = {
fontSize: "0.875rem",
color: "#868e96",
fontWeight: 600,
marginBottom: "4px",
},
padding,
mobileColumns = { base: 1, sm: 2 },
cardColumns = { base: 1, xs: 2 },
testId,
}: ResponsiveTableProps<T>) {
const realPadding = padding || "sm";
const [isMobile, setIsMobile] = useState(
window.innerWidth < mobileBreakpoint,
);

React.useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < mobileBreakpoint);
};

window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [mobileBreakpoint]);

const handleSort = (key: string) => {
if (onSort) {
onSort(key);
}
};

// Desktop table view
if (!isMobile) {
return (
<Table data-testid={testId}>
<Table.Thead>
<Table.Tr>
{columns.map((column) => {
if (column.sortable && onSort) {
return (
<Th
key={column.key}
sorted={sortBy === column.key}
reversed={sortReversed}
onSort={() => handleSort(column.key)}
>
{column.label}
</Th>
);
}
return (
<Table.Th key={column.key}>
<Text fw={500} size="sm">
{column.label}
</Text>
</Table.Th>
);
})}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.map((item) => {
const key = keyExtractor(item);
return (
<Table.Tr
key={key}
style={onRowClick ? { cursor: "pointer" } : undefined}
onClick={onRowClick ? () => onRowClick(item) : undefined}
data-testid={
testIdPrefix ? `${testIdPrefix}-${key}` : undefined
}
>
{columns.map((column) => (
<Table.Td key={`${key}-${column.key}`}>
{column.render(item)}
</Table.Td>
))}
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
);
}

// Mobile card view with responsive grid
return (
<SimpleGrid cols={mobileColumns} spacing={realPadding} pt="sm">
{data.map((item) => {
const key = keyExtractor(item);
return (
<Card
key={key}
withBorder
padding={realPadding}
style={onRowClick ? { cursor: "pointer" } : undefined}
onClick={onRowClick ? () => onRowClick(item) : undefined}
data-testid={testIdPrefix ? `${testIdPrefix}-${key}` : undefined}
>
<SimpleGrid cols={cardColumns} spacing="sm">
{columns.map((column) => {
const mobileLabel = column.mobileLabel || column.label;
const showLabel = !column.hideMobileLabel;
const labelStyle = column.mobileLabelStyle || mobileLabelStyle;
const isPrimary = column.isPrimaryColumn;

return (
<Box key={`${key}-${column.key}`}>
{showLabel && (
<Text
style={
isPrimary
? {
...labelStyle,
fontWeight: 700,
color: "#000",
}
: labelStyle
}
>
{mobileLabel}
</Text>
)}
<Box
style={
isPrimary
? {
fontSize: "1rem",
fontWeight: 600,
}
: {
fontSize: "0.875rem",
fontWeight: 400,
}
}
>
{column.render(item)}
</Box>
</Box>
);
})}
</SimpleGrid>
</Card>
);
})}
</SimpleGrid>
);
}

// Hook for sorting logic
export function useTableSort<T>(initialSortBy: string | null = null): {
sortBy: string | null;
reversedSort: boolean;
handleSort: (field: string) => void;
sortData: (data: T[], sortFn: (a: T, b: T, sortBy: string) => number) => T[];
} {
const [sortBy, setSortBy] = useState<string | null>(initialSortBy);
const [reversedSort, setReversedSort] = useState(false);

const handleSort = (field: string) => {
if (sortBy === field) {
setReversedSort((r) => !r);
} else {
setSortBy(field);
setReversedSort(false);
}
};

const sortData = (
data: T[],
sortFn: (a: T, b: T, sortBy: string) => number,
): T[] => {
if (!sortBy) {
return data;
}

return [...data].sort((a, b) => {
const comparison = sortFn(a, b, sortBy);
return reversedSort ? -comparison : comparison;
});
};

return { sortBy, reversedSort, handleSort, sortData };
}
30 changes: 18 additions & 12 deletions src/ui/pages/apiKeys/ManageKeysTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe("OrgApiKeyTable Tests", () => {
});

it("renders the table headers correctly", async () => {
getApiKeys.mockResolvedValue([]);
getApiKeys.mockResolvedValue(mockApiKeys);
await renderComponent();

expect(screen.getByText("Key ID")).toBeInTheDocument();
Expand Down Expand Up @@ -139,7 +139,11 @@ describe("OrgApiKeyTable Tests", () => {
await renderComponent();

await waitFor(() => {
expect(screen.getByText("No API keys found.")).toBeInTheDocument();
expect(
screen.getByText(
`No API keys found. Click "Create API Key" to get started.`,
),
).toBeInTheDocument();
});
});

Expand Down Expand Up @@ -172,8 +176,8 @@ describe("OrgApiKeyTable Tests", () => {
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes.length).toBeGreaterThan(1); // Header + rows

// Select first row
await user.click(checkboxes[1]); // First row checkbox (index 0 is header)
// Select first data row (skip the header checkbox at index 0)
await user.click(checkboxes[1]);

// Delete button should appear with count
expect(screen.getByText(/Delete 1 API Key/)).toBeInTheDocument();
Expand All @@ -185,7 +189,7 @@ describe("OrgApiKeyTable Tests", () => {
expect(screen.queryByText(/Delete 1 API Key/)).not.toBeInTheDocument();
});

it("allows selecting all rows with header checkbox", async () => {
it("allows selecting all rows with Select All button", async () => {
getApiKeys.mockResolvedValue(mockApiKeys);
await renderComponent();
const user = userEvent.setup();
Expand All @@ -195,22 +199,24 @@ describe("OrgApiKeyTable Tests", () => {
expect(screen.getByText("acmuiuc_key123")).toBeInTheDocument();
});

// Check that header checkbox exists
const headerCheckbox = screen.getAllByRole("checkbox")[0]; // Header checkbox
expect(headerCheckbox).toBeInTheDocument();
// Find and click the "Select All" button
const selectAllButton = screen.getByRole("button", { name: /Select All/i });
expect(selectAllButton).toBeInTheDocument();

// Click header checkbox
await act(async () => {
await user.click(headerCheckbox);
await user.click(selectAllButton);
});

// Delete button should show count of all rows
const deleteButton = await screen.findByText(/Delete 2 API Keys/);
expect(deleteButton).toBeInTheDocument();

// Uncheck all
// Click "Deselect All" button
const deselectAllButton = screen.getByRole("button", {
name: /Deselect All/i,
});
await act(async () => {
await user.click(headerCheckbox);
await user.click(deselectAllButton);
});

// Delete button should be gone
Expand Down
Loading
Loading