chore: lint/format all files (#604)
* chore: lint/format all files * Fix config sidebar button state (#602) * chore: Update deno.lock version and add Radix UI slider component (#601) * fix: improve how table addresses even/odd rows --------- Co-authored-by: philon- <philon-@users.noreply.github.com> Co-authored-by: Kamil Dzieniszewski <kamil.dzieniszewski@gmail.com>
This commit is contained in:
co-authored by
philon-
Kamil Dzieniszewski
parent
b7bdb1a502
commit
480ca46a95
@@ -1,111 +1,122 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Table } from "@components/generic/Table/index.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
|
||||
// @ts-types="react"
|
||||
import React from "react";
|
||||
|
||||
describe("Generic Table", () => {
|
||||
it("Can render an empty table.", () => {
|
||||
render(
|
||||
<Table
|
||||
headings={[]}
|
||||
rows={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
it("Can render an empty table.", () => {
|
||||
render(
|
||||
<Table
|
||||
headings={[]}
|
||||
rows={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Can render a table with headers and no rows.", async () => {
|
||||
render(
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "", type: "blank", sortable: false },
|
||||
{ title: "Short Name", type: "normal", sortable: true },
|
||||
{ title: "Long Name", type: "normal", sortable: true },
|
||||
{ title: "Model", type: "normal", sortable: true },
|
||||
{ title: "MAC Address", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "SNR", type: "normal", sortable: true },
|
||||
{ title: "Encryption", type: "normal", sortable: false },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
]}
|
||||
rows={[]}
|
||||
/>
|
||||
);
|
||||
await screen.findByRole('table');
|
||||
expect(screen.getAllByRole("columnheader")).toHaveLength(9);
|
||||
});
|
||||
it("Can render a table with headers and no rows.", async () => {
|
||||
render(
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "", type: "blank", sortable: false },
|
||||
{ title: "Short Name", type: "normal", sortable: true },
|
||||
{ title: "Long Name", type: "normal", sortable: true },
|
||||
{ title: "Model", type: "normal", sortable: true },
|
||||
{ title: "MAC Address", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "SNR", type: "normal", sortable: true },
|
||||
{ title: "Encryption", type: "normal", sortable: false },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
]}
|
||||
rows={[]}
|
||||
/>,
|
||||
);
|
||||
await screen.findByRole("table");
|
||||
expect(screen.getAllByRole("columnheader")).toHaveLength(9);
|
||||
});
|
||||
|
||||
// A simplified version of the rows in pages/Nodes.tsx for testing purposes
|
||||
const mockDevicesWithShortNameAndConnection = [
|
||||
{user: {shortName: "TST1"}, hopsAway: 1, lastHeard: Date.now() + 1000 },
|
||||
{user: {shortName: "TST2"}, hopsAway: 0, lastHeard: Date.now() + 4000 },
|
||||
{user: {shortName: "TST3"}, hopsAway: 4, lastHeard: Date.now() },
|
||||
{user: {shortName: "TST4"}, hopsAway: 3, lastHeard: Date.now() + 2000 }
|
||||
];
|
||||
|
||||
const mockRows = mockDevicesWithShortNameAndConnection.map(node => [
|
||||
<h1 data-testshortname> { node.user.shortName } </h1>,
|
||||
<><TimeAgo timestamp={node.lastHeard * 1000} /></>,
|
||||
<Mono key="hops" data-testhops>
|
||||
{node.lastHeard !== 0
|
||||
? node.hopsAway === 0
|
||||
? "Direct"
|
||||
: `${node.hopsAway?.toString()} ${
|
||||
node.hopsAway > 1 ? "hops" : "hop"
|
||||
} away`
|
||||
: "-"}
|
||||
</Mono>
|
||||
])
|
||||
// A simplified version of the rows in pages/Nodes.tsx for testing purposes
|
||||
const mockDevicesWithShortNameAndConnection = [
|
||||
{ user: { shortName: "TST1" }, hopsAway: 1, lastHeard: Date.now() + 1000 },
|
||||
{ user: { shortName: "TST2" }, hopsAway: 0, lastHeard: Date.now() + 4000 },
|
||||
{ user: { shortName: "TST3" }, hopsAway: 4, lastHeard: Date.now() },
|
||||
{ user: { shortName: "TST4" }, hopsAway: 3, lastHeard: Date.now() + 2000 },
|
||||
];
|
||||
|
||||
it("Can sort rows appropriately.", async () => {
|
||||
render(
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "Short Name", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
]}
|
||||
rows={mockRows}
|
||||
/>
|
||||
);
|
||||
const renderedTable = await screen.findByRole('table');
|
||||
const columnHeaders = screen.getAllByRole("columnheader");
|
||||
expect(columnHeaders).toHaveLength(3);
|
||||
const mockRows = mockDevicesWithShortNameAndConnection.map((node) => [
|
||||
<h1 data-testshortname key={node.user.shortName}>{node.user.shortName}</h1>,
|
||||
<React.Fragment key={node.user.shortName}>
|
||||
<TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
</React.Fragment>,
|
||||
<Mono key="hops" data-testhops>
|
||||
{node.lastHeard !== 0
|
||||
? node.hopsAway === 0
|
||||
? "Direct"
|
||||
: `${node.hopsAway?.toString()} ${
|
||||
node.hopsAway > 1 ? "hops" : "hop"
|
||||
} away`
|
||||
: "-"}
|
||||
</Mono>,
|
||||
]);
|
||||
|
||||
// Will be sorted "Last heard" "asc" by default
|
||||
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
|
||||
.map(el=>el.textContent)
|
||||
.map(v=>v?.trim())
|
||||
.join(','))
|
||||
.toMatch('TST2,TST4,TST1,TST3');
|
||||
|
||||
fireEvent.click(columnHeaders[0]);
|
||||
it("Can sort rows appropriately.", async () => {
|
||||
render(
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "Short Name", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
]}
|
||||
rows={mockRows}
|
||||
/>,
|
||||
);
|
||||
const renderedTable = await screen.findByRole("table");
|
||||
const columnHeaders = screen.getAllByRole("columnheader");
|
||||
expect(columnHeaders).toHaveLength(3);
|
||||
|
||||
// Re-sort by Short Name asc
|
||||
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
|
||||
.map(el=>el.textContent)
|
||||
.map(v=>v?.trim())
|
||||
.join(','))
|
||||
.toMatch('TST1,TST2,TST3,TST4');
|
||||
// Will be sorted "Last heard" "asc" by default
|
||||
expect(
|
||||
[...renderedTable.querySelectorAll("[data-testshortname]")]
|
||||
.map((el) => el.textContent)
|
||||
.map((v) => v?.trim())
|
||||
.join(","),
|
||||
)
|
||||
.toMatch("TST2,TST4,TST1,TST3");
|
||||
|
||||
fireEvent.click(columnHeaders[0]);
|
||||
fireEvent.click(columnHeaders[0]);
|
||||
|
||||
// Re-sort by Short Name desc
|
||||
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
|
||||
.map(el=>el.textContent)
|
||||
.map(v=>v?.trim())
|
||||
.join(','))
|
||||
.toMatch('TST4,TST3,TST2,TST1');
|
||||
// Re-sort by Short Name asc
|
||||
expect(
|
||||
[...renderedTable.querySelectorAll("[data-testshortname]")]
|
||||
.map((el) => el.textContent)
|
||||
.map((v) => v?.trim())
|
||||
.join(","),
|
||||
)
|
||||
.toMatch("TST1,TST2,TST3,TST4");
|
||||
|
||||
fireEvent.click(columnHeaders[2]);
|
||||
|
||||
// Re-sort by Hops Away
|
||||
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
|
||||
.map(el=>el.textContent)
|
||||
.map(v=>v?.trim())
|
||||
.join(','))
|
||||
.toMatch('TST2,TST1,TST4,TST3');
|
||||
});
|
||||
})
|
||||
fireEvent.click(columnHeaders[0]);
|
||||
|
||||
// Re-sort by Short Name desc
|
||||
expect(
|
||||
[...renderedTable.querySelectorAll("[data-testshortname]")]
|
||||
.map((el) => el.textContent)
|
||||
.map((v) => v?.trim())
|
||||
.join(","),
|
||||
)
|
||||
.toMatch("TST4,TST3,TST2,TST1");
|
||||
|
||||
fireEvent.click(columnHeaders[2]);
|
||||
|
||||
// Re-sort by Hops Away
|
||||
expect(
|
||||
[...renderedTable.querySelectorAll("[data-testshortname]")]
|
||||
.map((el) => el.textContent)
|
||||
.map((v) => v?.trim())
|
||||
.join(","),
|
||||
)
|
||||
.toMatch("TST2,TST1,TST4,TST3");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
export interface TableProps {
|
||||
headings: Heading[];
|
||||
rows: [][];
|
||||
rows: React.ReactNode[][];
|
||||
}
|
||||
|
||||
export interface Heading {
|
||||
@@ -12,18 +13,15 @@ export interface Heading {
|
||||
sortable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param hopsAway String describing the number of hops away the node is from the current node
|
||||
* @returns number of hopsAway or `0` if hopsAway is 'Direct'
|
||||
*/
|
||||
function numericHops(hopsAway: string): number {
|
||||
function numericHops(hopsAway: string | unknown): number {
|
||||
if (typeof hopsAway !== "string") {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
if (hopsAway.match(/direct/i)) {
|
||||
return 0;
|
||||
}
|
||||
if (hopsAway.match(/\d+\s+hop/gi)) {
|
||||
return Number(hopsAway.match(/(\d+)\s+hop/i)?.[1]);
|
||||
}
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
const match = hopsAway.match(/(\d+)\s+hop/i);
|
||||
return Number(match?.[1] ?? Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
export const Table = ({ headings, rows }: TableProps) => {
|
||||
@@ -39,45 +37,63 @@ export const Table = ({ headings, rows }: TableProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getElement = (cell: React.ReactNode): React.ReactElement | null => {
|
||||
if (!React.isValidElement(cell)) {
|
||||
return null;
|
||||
}
|
||||
if (cell.type === React.Fragment) {
|
||||
const childrenArray = React.Children.toArray(cell.props.children);
|
||||
const firstElement = childrenArray.find((child) =>
|
||||
React.isValidElement(child)
|
||||
);
|
||||
return (firstElement as React.ReactElement) ?? null;
|
||||
}
|
||||
// If not a fragment, return the element itself
|
||||
return cell;
|
||||
};
|
||||
|
||||
const sortedRows = rows.slice().sort((a, b) => {
|
||||
if (!sortColumn) return 0;
|
||||
|
||||
const columnIndex = headings.findIndex((h) => h.title === sortColumn);
|
||||
const aValue = a[columnIndex].props.children;
|
||||
const bValue = b[columnIndex].props.children;
|
||||
if (columnIndex === -1) return 0;
|
||||
|
||||
const elementA = getElement(a[columnIndex]);
|
||||
const elementB = getElement(b[columnIndex]);
|
||||
|
||||
if (sortColumn === "Last Heard") {
|
||||
const aTimestamp = aValue.props.timestamp ?? 0;
|
||||
const bTimestamp = bValue.props.timestamp ?? 0;
|
||||
|
||||
if (aTimestamp < bTimestamp) {
|
||||
return sortOrder === "asc" ? -1 : 1;
|
||||
}
|
||||
if (aTimestamp > bTimestamp) {
|
||||
return sortOrder === "asc" ? 1 : -1;
|
||||
}
|
||||
const aTimestamp = elementA?.props?.timestamp ?? 0;
|
||||
const bTimestamp = elementB?.props?.timestamp ?? 0;
|
||||
if (aTimestamp < bTimestamp) return sortOrder === "asc" ? -1 : 1;
|
||||
if (aTimestamp > bTimestamp) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (sortColumn === "Connection") {
|
||||
const aNumHops = numericHops(aValue instanceof Array ? aValue[0] : aValue);
|
||||
const bNumHops = numericHops(bValue instanceof Array ? bValue[0] : bValue);
|
||||
|
||||
if (aNumHops < bNumHops) {
|
||||
return sortOrder === "asc" ? -1 : 1;
|
||||
}
|
||||
if (aNumHops > bNumHops) {
|
||||
return sortOrder === "asc" ? 1 : -1;
|
||||
}
|
||||
const aHopsStr = elementA?.props?.children;
|
||||
const bHopsStr = elementB?.props?.children;
|
||||
const aNumHops = numericHops(aHopsStr);
|
||||
const bNumHops = numericHops(bHopsStr);
|
||||
if (aNumHops < bNumHops) return sortOrder === "asc" ? -1 : 1;
|
||||
if (aNumHops > bNumHops) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aValue < bValue) {
|
||||
return sortOrder === "asc" ? -1 : 1;
|
||||
}
|
||||
if (aValue > bValue) {
|
||||
return sortOrder === "asc" ? 1 : -1;
|
||||
}
|
||||
const aValue = elementA?.props?.children;
|
||||
const bValue = elementB?.props?.children;
|
||||
const valA = aValue ?? "";
|
||||
const valB = bValue ?? "";
|
||||
|
||||
// Ensure consistent comparison for potentially different types
|
||||
const compareA = typeof valA === "string" || typeof valA === "number"
|
||||
? valA
|
||||
: String(valA);
|
||||
const compareB = typeof valB === "string" || typeof valB === "number"
|
||||
? valB
|
||||
: String(valB);
|
||||
|
||||
if (compareA < compareB) return sortOrder === "asc" ? -1 : 1;
|
||||
if (compareA > compareB) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
@@ -89,48 +105,76 @@ export const Table = ({ headings, rows }: TableProps) => {
|
||||
<th
|
||||
key={heading.title}
|
||||
scope="col"
|
||||
className={`py-2 pr-3 text-left ${heading.sortable
|
||||
? "cursor-pointer hover:brightness-hover active:brightness-press"
|
||||
: ""
|
||||
}`}
|
||||
className={`py-2 pr-3 text-left ${
|
||||
heading.sortable
|
||||
? "cursor-pointer hover:brightness-hover active:brightness-press"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => heading.sortable && headingSort(heading.title)}
|
||||
onKeyUp={() => heading.sortable && headingSort(heading.title)}
|
||||
onKeyUp={(e) => {
|
||||
if (heading.sortable && (e.key === "Enter" || e.key === " ")) {
|
||||
headingSort(heading.title);
|
||||
}
|
||||
}}
|
||||
tabIndex={heading.sortable ? 0 : -1}
|
||||
role="columnheader"
|
||||
aria-sort={sortColumn === heading.title
|
||||
? sortOrder === "asc" ? "ascending" : "descending"
|
||||
: "none"}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{heading.title}
|
||||
{sortColumn === heading.title &&
|
||||
(sortOrder === "asc"
|
||||
? <ChevronUpIcon size={16} />
|
||||
: <ChevronDownIcon size={16} />)}
|
||||
{heading.sortable && sortColumn === heading.title && (
|
||||
sortOrder === "asc"
|
||||
? <ChevronUpIcon size={16} aria-hidden="true" />
|
||||
: <ChevronDownIcon size={16} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="max-w-fit">
|
||||
{sortedRows.map((row, index) => {
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed.
|
||||
return (<tr key={index} className={`${index % 2 ? 'bg-white dark:bg-white/2' : 'bg-slate-50/50 dark:bg-slate-50/5'} border-b-1 border-slate-200 dark:border-slate-900`}>
|
||||
{row.map((item, index) => {
|
||||
return (index === 0 ?
|
||||
<th
|
||||
key={item.key ?? index}
|
||||
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
|
||||
scope="row"
|
||||
>
|
||||
{item}
|
||||
</th> :
|
||||
<td
|
||||
key={item.key ?? index}
|
||||
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
|
||||
>
|
||||
{item}
|
||||
</td>)
|
||||
})}
|
||||
</tr>
|
||||
{sortedRows.map((row) => {
|
||||
const firstCellKey =
|
||||
(React.isValidElement(row[0]) && row[0].key !== null)
|
||||
? String(row[0].key)
|
||||
: null;
|
||||
const rowKey = firstCellKey ?? Math.random().toString(); // Use random only as last resort
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={rowKey}
|
||||
className={`
|
||||
bg-white dark:bg-white/10
|
||||
odd:bg-slate-800/70 dark:even:bg-slate-900/70
|
||||
`}
|
||||
>
|
||||
{row.map((item, cellIndex) => {
|
||||
const cellKey = `${rowKey}_${cellIndex}`;
|
||||
return cellIndex === 0
|
||||
? (
|
||||
<th
|
||||
key={cellKey}
|
||||
className="whitespace-nowrap px-3 py-2 text-sm text-left text-text-secondary"
|
||||
scope="row"
|
||||
>
|
||||
{item}
|
||||
</th>
|
||||
)
|
||||
: (
|
||||
<td
|
||||
key={cellKey}
|
||||
className="whitespace-nowrap px-3 py-2 text-sm text-text-secondary"
|
||||
>
|
||||
{item}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user