Skip to content

Commit ac0f400

Browse files
authored
Use a responsive cards-based template for tables on mobile (#320)
1 parent 9757781 commit ac0f400

23 files changed

+1584
-1433
lines changed
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import {
2+
Table,
3+
Card,
4+
Text,
5+
SimpleGrid,
6+
Stack,
7+
UnstyledButton,
8+
Center,
9+
Box,
10+
type MantineSpacing,
11+
} from "@mantine/core";
12+
import {
13+
IconChevronUp,
14+
IconChevronDown,
15+
IconSelector,
16+
} from "@tabler/icons-react";
17+
import React, { useState } from "react";
18+
19+
// Types
20+
export interface Column<T> {
21+
key: string;
22+
label: string;
23+
sortable?: boolean;
24+
render: (item: T) => React.ReactNode;
25+
mobileLabel?: string; // Optional different label for mobile
26+
hideMobileLabel?: boolean; // Hide label in mobile card view
27+
mobileLabelStyle?: React.CSSProperties; // Custom styles for mobile label
28+
cardColumn?: number; // Which column this field should appear in (1 or 2), defaults to auto-flow
29+
isPrimaryColumn?: boolean; // Bold and emphasize this column in mobile cards
30+
}
31+
32+
export interface ResponsiveTableProps<T> {
33+
data: T[];
34+
columns: Column<T>[];
35+
keyExtractor: (item: T) => string;
36+
onSort?: (key: string) => void;
37+
sortBy?: string | null;
38+
sortReversed?: boolean;
39+
testIdPrefix?: string;
40+
onRowClick?: (item: T) => void;
41+
mobileBreakpoint?: number; // px value, defaults to 768
42+
mobileLabelStyle?: React.CSSProperties; // Default style for all mobile labels
43+
padding?: MantineSpacing;
44+
mobileColumns?:
45+
| number
46+
| { base?: number; xs?: number; sm?: number; md?: number }; // Grid columns for mobile cards
47+
cardColumns?: number | { base?: number; xs?: number; sm?: number }; // Columns inside each card
48+
testId?: string; // Table data-testId
49+
}
50+
51+
interface ThProps {
52+
children: React.ReactNode;
53+
reversed: boolean;
54+
sorted: boolean;
55+
onSort: () => void;
56+
}
57+
58+
function Th({ children, reversed, sorted, onSort }: ThProps) {
59+
const Icon = sorted
60+
? reversed
61+
? IconChevronUp
62+
: IconChevronDown
63+
: IconSelector;
64+
65+
return (
66+
<Table.Th>
67+
<UnstyledButton
68+
onClick={onSort}
69+
style={{ display: "flex", alignItems: "center", gap: "4px" }}
70+
>
71+
<Text fw={500} size="sm">
72+
{children}
73+
</Text>
74+
<Center>
75+
<Icon size={14} stroke={1.5} />
76+
</Center>
77+
</UnstyledButton>
78+
</Table.Th>
79+
);
80+
}
81+
82+
export function ResponsiveTable<T>({
83+
data,
84+
columns,
85+
keyExtractor,
86+
onSort,
87+
sortBy = null,
88+
sortReversed = false,
89+
testIdPrefix,
90+
onRowClick,
91+
mobileBreakpoint = 768,
92+
mobileLabelStyle = {
93+
fontSize: "0.875rem",
94+
color: "#868e96",
95+
fontWeight: 600,
96+
marginBottom: "4px",
97+
},
98+
padding,
99+
mobileColumns = { base: 1, sm: 2 },
100+
cardColumns = { base: 1, xs: 2 },
101+
testId,
102+
}: ResponsiveTableProps<T>) {
103+
const realPadding = padding || "sm";
104+
const [isMobile, setIsMobile] = useState(
105+
window.innerWidth < mobileBreakpoint,
106+
);
107+
108+
React.useEffect(() => {
109+
const handleResize = () => {
110+
setIsMobile(window.innerWidth < mobileBreakpoint);
111+
};
112+
113+
window.addEventListener("resize", handleResize);
114+
return () => window.removeEventListener("resize", handleResize);
115+
}, [mobileBreakpoint]);
116+
117+
const handleSort = (key: string) => {
118+
if (onSort) {
119+
onSort(key);
120+
}
121+
};
122+
123+
// Desktop table view
124+
if (!isMobile) {
125+
return (
126+
<Table data-testid={testId}>
127+
<Table.Thead>
128+
<Table.Tr>
129+
{columns.map((column) => {
130+
if (column.sortable && onSort) {
131+
return (
132+
<Th
133+
key={column.key}
134+
sorted={sortBy === column.key}
135+
reversed={sortReversed}
136+
onSort={() => handleSort(column.key)}
137+
>
138+
{column.label}
139+
</Th>
140+
);
141+
}
142+
return (
143+
<Table.Th key={column.key}>
144+
<Text fw={500} size="sm">
145+
{column.label}
146+
</Text>
147+
</Table.Th>
148+
);
149+
})}
150+
</Table.Tr>
151+
</Table.Thead>
152+
<Table.Tbody>
153+
{data.map((item) => {
154+
const key = keyExtractor(item);
155+
return (
156+
<Table.Tr
157+
key={key}
158+
style={onRowClick ? { cursor: "pointer" } : undefined}
159+
onClick={onRowClick ? () => onRowClick(item) : undefined}
160+
data-testid={
161+
testIdPrefix ? `${testIdPrefix}-${key}` : undefined
162+
}
163+
>
164+
{columns.map((column) => (
165+
<Table.Td key={`${key}-${column.key}`}>
166+
{column.render(item)}
167+
</Table.Td>
168+
))}
169+
</Table.Tr>
170+
);
171+
})}
172+
</Table.Tbody>
173+
</Table>
174+
);
175+
}
176+
177+
// Mobile card view with responsive grid
178+
return (
179+
<SimpleGrid cols={mobileColumns} spacing={realPadding} pt="sm">
180+
{data.map((item) => {
181+
const key = keyExtractor(item);
182+
return (
183+
<Card
184+
key={key}
185+
withBorder
186+
padding={realPadding}
187+
style={onRowClick ? { cursor: "pointer" } : undefined}
188+
onClick={onRowClick ? () => onRowClick(item) : undefined}
189+
data-testid={testIdPrefix ? `${testIdPrefix}-${key}` : undefined}
190+
>
191+
<SimpleGrid cols={cardColumns} spacing="sm">
192+
{columns.map((column) => {
193+
const mobileLabel = column.mobileLabel || column.label;
194+
const showLabel = !column.hideMobileLabel;
195+
const labelStyle = column.mobileLabelStyle || mobileLabelStyle;
196+
const isPrimary = column.isPrimaryColumn;
197+
198+
return (
199+
<Box key={`${key}-${column.key}`}>
200+
{showLabel && (
201+
<Text
202+
style={
203+
isPrimary
204+
? {
205+
...labelStyle,
206+
fontWeight: 700,
207+
color: "#000",
208+
}
209+
: labelStyle
210+
}
211+
>
212+
{mobileLabel}
213+
</Text>
214+
)}
215+
<Box
216+
style={
217+
isPrimary
218+
? {
219+
fontSize: "1rem",
220+
fontWeight: 600,
221+
}
222+
: {
223+
fontSize: "0.875rem",
224+
fontWeight: 400,
225+
}
226+
}
227+
>
228+
{column.render(item)}
229+
</Box>
230+
</Box>
231+
);
232+
})}
233+
</SimpleGrid>
234+
</Card>
235+
);
236+
})}
237+
</SimpleGrid>
238+
);
239+
}
240+
241+
// Hook for sorting logic
242+
export function useTableSort<T>(initialSortBy: string | null = null): {
243+
sortBy: string | null;
244+
reversedSort: boolean;
245+
handleSort: (field: string) => void;
246+
sortData: (data: T[], sortFn: (a: T, b: T, sortBy: string) => number) => T[];
247+
} {
248+
const [sortBy, setSortBy] = useState<string | null>(initialSortBy);
249+
const [reversedSort, setReversedSort] = useState(false);
250+
251+
const handleSort = (field: string) => {
252+
if (sortBy === field) {
253+
setReversedSort((r) => !r);
254+
} else {
255+
setSortBy(field);
256+
setReversedSort(false);
257+
}
258+
};
259+
260+
const sortData = (
261+
data: T[],
262+
sortFn: (a: T, b: T, sortBy: string) => number,
263+
): T[] => {
264+
if (!sortBy) {
265+
return data;
266+
}
267+
268+
return [...data].sort((a, b) => {
269+
const comparison = sortFn(a, b, sortBy);
270+
return reversedSort ? -comparison : comparison;
271+
});
272+
};
273+
274+
return { sortBy, reversedSort, handleSort, sortData };
275+
}

src/ui/pages/apiKeys/ManageKeysTable.test.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe("OrgApiKeyTable Tests", () => {
9999
});
100100

101101
it("renders the table headers correctly", async () => {
102-
getApiKeys.mockResolvedValue([]);
102+
getApiKeys.mockResolvedValue(mockApiKeys);
103103
await renderComponent();
104104

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

141141
await waitFor(() => {
142-
expect(screen.getByText("No API keys found.")).toBeInTheDocument();
142+
expect(
143+
screen.getByText(
144+
`No API keys found. Click "Create API Key" to get started.`,
145+
),
146+
).toBeInTheDocument();
143147
});
144148
});
145149

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

175-
// Select first row
176-
await user.click(checkboxes[1]); // First row checkbox (index 0 is header)
179+
// Select first data row (skip the header checkbox at index 0)
180+
await user.click(checkboxes[1]);
177181

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

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

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

202-
// Click header checkbox
203206
await act(async () => {
204-
await user.click(headerCheckbox);
207+
await user.click(selectAllButton);
205208
});
206209

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

211-
// Uncheck all
214+
// Click "Deselect All" button
215+
const deselectAllButton = screen.getByRole("button", {
216+
name: /Deselect All/i,
217+
});
212218
await act(async () => {
213-
await user.click(headerCheckbox);
219+
await user.click(deselectAllButton);
214220
});
215221

216222
// Delete button should be gone

0 commit comments

Comments
 (0)