Skip to content

Commit 8e04177

Browse files
authored
Merge pull request #227 from IyanuOluwaJesuloba/feature/data-table-component
Feature/data table component
2 parents 7f46b9c + 3b5b370 commit 8e04177

File tree

8 files changed

+2036
-1892
lines changed

8 files changed

+2036
-1892
lines changed

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@dnd-kit/core": "^6.3.1",
2121
"@dnd-kit/sortable": "^10.0.0",
2222
"@dnd-kit/utilities": "^3.2.2",
23+
"@tanstack/react-table": "^8.21.3",
2324
"@tanstack/react-query": "^5.59.20",
2425
"@tanstack/react-table": "^8.21.3",
2526
"@tanstack/react-virtual": "^3.13.12",

frontend/src/components/DataTable/ColumnFilter.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function ColumnFilter<TData>({
2121

2222
return (
2323
<select
24-
className="mt-2 w-full bg-stellar-card border border-stellar-border rounded px-2 py-1 text-xs text-stellar-text-primary"
24+
className="mt-2 w-full bg-stellar-dark border border-stellar-border rounded px-2 py-1 text-xs text-white"
2525
value={v === "" ? "" : v ? "true" : "false"}
2626
onChange={(e) => {
2727
const next = e.target.value;
@@ -41,7 +41,7 @@ export function ColumnFilter<TData>({
4141

4242
return (
4343
<select
44-
className="mt-2 w-full bg-stellar-card border border-stellar-border rounded px-2 py-1 text-xs text-stellar-text-primary"
44+
className="mt-2 w-full bg-stellar-dark border border-stellar-border rounded px-2 py-1 text-xs text-white"
4545
value={v}
4646
onChange={(e) => {
4747
const next = e.target.value;
@@ -65,7 +65,7 @@ export function ColumnFilter<TData>({
6565
return (
6666
<div className="mt-2 flex items-center gap-2">
6767
<input
68-
className="w-full bg-stellar-card border border-stellar-border rounded px-2 py-1 text-xs text-stellar-text-primary"
68+
className="w-full bg-stellar-dark border border-stellar-border rounded px-2 py-1 text-xs text-white"
6969
placeholder="Min"
7070
inputMode="decimal"
7171
value={typeof min === "number" ? String(min) : ""}
@@ -79,7 +79,7 @@ export function ColumnFilter<TData>({
7979
}}
8080
/>
8181
<input
82-
className="w-full bg-stellar-card border border-stellar-border rounded px-2 py-1 text-xs text-stellar-text-primary"
82+
className="w-full bg-stellar-dark border border-stellar-border rounded px-2 py-1 text-xs text-white"
8383
placeholder="Max"
8484
inputMode="decimal"
8585
value={typeof max === "number" ? String(max) : ""}
@@ -104,7 +104,7 @@ export function ColumnFilter<TData>({
104104
<div className="mt-2 flex items-center gap-2">
105105
<input
106106
type="date"
107-
className="w-full bg-stellar-card border border-stellar-border rounded px-2 py-1 text-xs text-stellar-text-primary"
107+
className="w-full bg-stellar-dark border border-stellar-border rounded px-2 py-1 text-xs text-white"
108108
value={typeof from === "string" ? from : ""}
109109
onChange={(e) => {
110110
const next: [string | undefined, string | undefined] = [
@@ -116,7 +116,7 @@ export function ColumnFilter<TData>({
116116
/>
117117
<input
118118
type="date"
119-
className="w-full bg-stellar-card border border-stellar-border rounded px-2 py-1 text-xs text-stellar-text-primary"
119+
className="w-full bg-stellar-dark border border-stellar-border rounded px-2 py-1 text-xs text-white"
120120
value={typeof to === "string" ? to : ""}
121121
onChange={(e) => {
122122
const next: [string | undefined, string | undefined] = [
@@ -134,7 +134,7 @@ export function ColumnFilter<TData>({
134134

135135
return (
136136
<input
137-
className="mt-2 w-full bg-stellar-card border border-stellar-border rounded px-2 py-1 text-xs text-stellar-text-primary"
137+
className="mt-2 w-full bg-stellar-dark border border-stellar-border rounded px-2 py-1 text-xs text-white"
138138
placeholder="Filter…"
139139
value={text}
140140
onChange={(e) => column.setFilterValue(e.target.value)}

frontend/src/components/DataTable/DataTable.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,24 @@ import { TablePagination } from "./TablePagination";
1515
import { useDataTable } from "./useDataTable";
1616
import type { DataTableColumnDef, DataTableRowAction } from "./types";
1717

18+
function withFilterFns<TData extends RowData>(
19+
cols: Array<DataTableColumnDef<TData>>
20+
): Array<DataTableColumnDef<TData>> {
21+
return cols.map((c) => {
22+
if (!c.filterType) return c;
23+
if ("filterFn" in c && c.filterFn) return c;
24+
25+
if (c.filterType === "numberRange") return { ...c, filterFn: "numberRange" };
26+
if (c.filterType === "dateRange") return { ...c, filterFn: "dateRange" };
27+
28+
// For these filter UIs we can safely use built-in filter fns.
29+
if (c.filterType === "boolean") return { ...c, filterFn: "equals" };
30+
if (c.filterType === "select") return { ...c, filterFn: "equals" };
31+
32+
return c;
33+
});
34+
}
35+
1836
declare module "@tanstack/react-table" {
1937
interface FilterFns {
2038
numberRange: FilterFn<unknown>;
@@ -102,7 +120,8 @@ export function DataTable<TData extends RowData>({
102120
} = useDataTable<TData>({ columns, defaultPageSize: pageSizeOptions?.[0] ?? 10 });
103121

104122
const columnsWithSelection = useMemo(() => {
105-
if (!enableRowSelection) return columns;
123+
const mappedColumns = withFilterFns(columns);
124+
if (!enableRowSelection) return mappedColumns;
106125

107126
const selectionCol: DataTableColumnDef<TData> = {
108127
id: "select",
@@ -131,7 +150,7 @@ export function DataTable<TData extends RowData>({
131150
size: 40,
132151
};
133152

134-
return [selectionCol, ...columns];
153+
return [selectionCol, ...mappedColumns];
135154
}, [columns, enableRowSelection]);
136155

137156
const table = useReactTable({

frontend/src/components/DataTable/TableBody.tsx

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,52 @@ function RowActionsMenu<TData>({
2020
rowActions: DataTableRowAction<TData>;
2121
}) {
2222
const [open, setOpen] = useState(false);
23+
const containerRef = useRef<HTMLDivElement | null>(null);
24+
const firstItemRef = useRef<HTMLButtonElement | null>(null);
25+
const buttonRef = useRef<HTMLButtonElement | null>(null);
26+
27+
useEffect(() => {
28+
if (!open) return;
29+
const t = window.setTimeout(() => firstItemRef.current?.focus(), 0);
30+
return () => window.clearTimeout(t);
31+
}, [open]);
32+
33+
useEffect(() => {
34+
if (!open) return;
35+
36+
function onMouseDown(e: MouseEvent) {
37+
const el = containerRef.current;
38+
if (!el) return;
39+
if (!el.contains(e.target as Node)) {
40+
setOpen(false);
41+
buttonRef.current?.focus();
42+
}
43+
}
44+
45+
document.addEventListener("mousedown", onMouseDown);
46+
return () => document.removeEventListener("mousedown", onMouseDown);
47+
}, [open]);
2348

2449
return (
25-
<div className="relative">
50+
<div
51+
ref={containerRef}
52+
className="relative"
53+
onKeyDown={(e) => {
54+
if (e.key === "Escape") {
55+
e.preventDefault();
56+
setOpen(false);
57+
buttonRef.current?.focus();
58+
}
59+
}}
60+
>
2661
<button
2762
type="button"
28-
className="px-2 py-1 rounded border border-stellar-border text-stellar-text-primary"
63+
className="px-2 py-1 rounded border border-stellar-border text-white"
2964
aria-haspopup="menu"
3065
aria-expanded={open}
66+
aria-label={rowActions.label ?? "Row actions"}
3167
onClick={() => setOpen((v) => !v)}
68+
ref={buttonRef}
3269
>
3370
3471
</button>
@@ -42,11 +79,13 @@ function RowActionsMenu<TData>({
4279
key={item.id}
4380
type="button"
4481
role="menuitem"
45-
className="w-full text-left px-3 py-2 text-sm text-stellar-text-primary hover:bg-stellar-border/30 disabled:opacity-40"
82+
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-stellar-dark disabled:opacity-40"
4683
disabled={item.disabled}
84+
ref={item.id === (rowActions.items?.[0]?.id ?? "") ? firstItemRef : undefined}
4785
onClick={() => {
4886
item.onSelect(row.original);
4987
setOpen(false);
88+
buttonRef.current?.focus();
5089
}}
5190
>
5291
{item.label}
@@ -181,7 +220,7 @@ export function TableBody<TData>({
181220
<tr
182221
key={row.id}
183222
className={`border-b border-stellar-border ${
184-
idx === focusedRowIndex ? "bg-stellar-border/30" : ""
223+
idx === focusedRowIndex ? "bg-stellar-dark/40" : ""
185224
}`}
186225
onMouseEnter={() => setFocusedRowIndex(idx)}
187226
>
@@ -209,7 +248,7 @@ export function TableBody<TData>({
209248
<tr
210249
key={row.id}
211250
className={`border-b border-stellar-border ${
212-
idx === focusedRowIndex ? "bg-stellar-border/30" : ""
251+
idx === focusedRowIndex ? "bg-stellar-dark/40" : ""
213252
}`}
214253
onMouseEnter={() => setFocusedRowIndex(idx)}
215254
>

frontend/src/components/DataTable/TableHeader.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,28 +50,52 @@ function SortableHeaderCell<TData>({
5050
const sortIndicator =
5151
sort === "asc" ? "▲" : sort === "desc" ? "▼" : "";
5252

53+
const ariaSort: "ascending" | "descending" | "none" =
54+
sort === "asc" ? "ascending" : sort === "desc" ? "descending" : "none";
55+
5356
return (
5457
<th
5558
ref={setNodeRef}
5659
style={style}
5760
scope="col"
5861
className="pb-3 pr-4 align-top"
62+
aria-sort={canSort ? ariaSort : undefined}
5963
>
6064
<div className="flex items-start justify-between gap-2">
6165
<button
6266
type="button"
6367
className={`text-left w-full ${canSort ? "cursor-pointer" : "cursor-default"}`}
6468
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
69+
onKeyDown={(e) => {
70+
if (!canSort) return;
71+
if (e.key === "Enter" || e.key === " ") {
72+
e.preventDefault();
73+
header.column.toggleSorting(undefined, e.shiftKey);
74+
}
75+
}}
6576
aria-label={canSort ? `Sort by ${id}` : undefined}
77+
aria-describedby={canSort ? `${header.id}-sort-hint` : undefined}
6678
>
6779
<div className="flex items-center gap-2 text-stellar-text-secondary">
68-
<span className="select-none" {...attributes} {...listeners}>
80+
<span
81+
className="select-none"
82+
{...attributes}
83+
{...listeners}
84+
aria-label="Reorder column"
85+
role="button"
86+
tabIndex={-1}
87+
>
6988
7089
</span>
7190
<span className="text-stellar-text-secondary">
7291
{flexRender(header.column.columnDef.header, header.getContext())}
7392
</span>
7493
<span className="text-xs text-stellar-text-secondary">{sortIndicator}</span>
94+
{canSort ? (
95+
<span id={`${header.id}-sort-hint`} className="sr-only">
96+
Press Enter or Space to sort. Hold Shift for multi-sort.
97+
</span>
98+
) : null}
7599
</div>
76100
</button>
77101
</div>

frontend/src/components/DataTable/TablePagination.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function TablePagination<TData>({
2424
</label>
2525
<select
2626
id="page-size"
27-
className="bg-stellar-card border border-stellar-border rounded px-2 py-1 text-sm text-stellar-text-primary"
27+
className="bg-stellar-dark border border-stellar-border rounded px-2 py-1 text-sm text-white"
2828
value={pageSize}
2929
onChange={(e) => table.setPageSize(Number(e.target.value))}
3030
>

frontend/src/pages/AssetDetail.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import AssetHeader from "../components/AssetHeader";
55
import HealthBreakdown from "../components/HealthBreakdown";
66
import { EnhancedPriceChart } from "../components/PriceChart";
77
import LiquidityDepthChart from "../components/LiquidityDepthChart";
8+
import type { DataTableColumnDef } from "../components/DataTable";
9+
import { DataTable } from "../components/DataTable";
10+
import type { CellContext } from "@tanstack/react-table";
811
import RefreshControls from "../components/RefreshControls";
912
import { ErrorBoundary, LoadingSpinner } from "../components/Skeleton";
1013
import VolumeAnalytics from "../components/VolumeAnalytics";
@@ -35,6 +38,46 @@ export default function AssetDetail() {
3538
setTimeframe,
3639
} = useAssetDetail(symbol ?? "");
3740

41+
const priceSourceRows = (priceData?.sources ?? []) as Array<{
42+
source: string;
43+
price: number;
44+
timestamp: string;
45+
}>;
46+
47+
const priceSourceColumns: Array<
48+
DataTableColumnDef<{
49+
source: string;
50+
price: number;
51+
timestamp: string;
52+
}>
53+
> = [
54+
{
55+
id: "source",
56+
accessorKey: "source",
57+
header: "Source",
58+
filterType: "text",
59+
},
60+
{
61+
id: "price",
62+
accessorKey: "price",
63+
header: "Price",
64+
filterType: "numberRange",
65+
cell: (
66+
ctx: CellContext<
67+
{ source: string; price: number; timestamp: string },
68+
unknown
69+
>
70+
) =>
71+
`$${Number(ctx.getValue()).toFixed(4)}`,
72+
},
73+
{
74+
id: "timestamp",
75+
accessorKey: "timestamp",
76+
header: "Last Updated",
77+
filterType: "text",
78+
},
79+
];
80+
3881
if (!symbol) {
3982
return <div className="text-stellar-text-secondary p-8">No asset symbol provided.</div>;
4083
}
@@ -118,6 +161,33 @@ export default function AssetDetail() {
118161
<VolumeAnalytics data={volume.data} isLoading={volume.isLoading} />
119162
)}
120163

164+
<DataTable
165+
data={priceSourceRows}
166+
columns={priceSourceColumns}
167+
isLoading={!priceData}
168+
title="Price Sources"
169+
description={`Price sources for ${symbol} including last update times`}
170+
pageSizeOptions={[10, 20, 50]}
171+
filenameBase={`${symbol}-price-sources`}
172+
enableRowSelection={true}
173+
enableMultiSort={true}
174+
enableColumnReorder={true}
175+
enableVirtualization={true}
176+
rowActions={{
177+
items: [
178+
{
179+
id: "copy-source",
180+
label: "Copy source",
181+
onSelect: (row) => {
182+
void navigator.clipboard.writeText(row.source);
183+
},
184+
},
185+
],
186+
}}
187+
/>
188+
</div>
189+
</Suspense>
190+
</ErrorBoundary>
121191
{activeTab === TabId.Alerts && (
122192
<AlertConfigSection
123193
alerts={alerts.data}

0 commit comments

Comments
 (0)