mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-03 08:21:25 +02:00
Merge pull request #297 from jkingsman/make-repeater-sortable
Make repeater neighbor pane sortable. Closes #290.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, lazy, Suspense } from 'react';
|
import { useMemo, useState, useCallback, lazy, Suspense } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
|
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
|
||||||
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
|
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
|
||||||
@@ -15,6 +15,49 @@ const NeighborsMiniMap = lazy(() =>
|
|||||||
import('../NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap }))
|
import('../NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap }))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type SortField = 'name' | 'snr' | 'distance' | 'last_heard';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
|
// Direction applied when a column is first selected. Name reads naturally A→Z
|
||||||
|
// and nearest-first/most-recent-first are the intuitive starting points; SNR
|
||||||
|
// leads with the strongest signal to preserve the previous default ordering.
|
||||||
|
const DEFAULT_DIR: Record<SortField, SortDir> = {
|
||||||
|
name: 'asc',
|
||||||
|
snr: 'desc',
|
||||||
|
distance: 'asc',
|
||||||
|
last_heard: 'asc',
|
||||||
|
};
|
||||||
|
|
||||||
|
function SortableHeader({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
sortField,
|
||||||
|
sortDir,
|
||||||
|
onSort,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
field: SortField;
|
||||||
|
sortField: SortField;
|
||||||
|
sortDir: SortDir;
|
||||||
|
onSort: (field: SortField) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const active = sortField === field;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={cn(
|
||||||
|
'pb-1 font-medium cursor-pointer select-none hover:text-foreground transition-colors',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={() => onSort(field)}
|
||||||
|
aria-sort={active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||||
|
>
|
||||||
|
{label} {active ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function NeighborsPane({
|
export function NeighborsPane({
|
||||||
data,
|
data,
|
||||||
state,
|
state,
|
||||||
@@ -71,19 +114,37 @@ export function NeighborsPane({
|
|||||||
? 'Waiting for repeater position'
|
? 'Waiting for repeater position'
|
||||||
: 'No repeater position available';
|
: 'No repeater position available';
|
||||||
|
|
||||||
// Resolve contact data for each neighbor in a single pass — used for
|
const [sortField, setSortField] = useState<SortField>('snr');
|
||||||
// coords (mini-map), distances (table column), and sorted display order.
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||||
const { neighborsWithCoords, sorted, hasDistances } = useMemo(() => {
|
|
||||||
|
const handleSort = useCallback(
|
||||||
|
(field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDir(DEFAULT_DIR[field]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sortField]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve contact data for each neighbor in a single pass — used for coords
|
||||||
|
// (mini-map) and distances (table column + distance sort). The formatted
|
||||||
|
// string drives display; the raw km drives numeric distance sorting.
|
||||||
|
const { neighborsWithCoords, enriched, hasDistances } = useMemo(() => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return {
|
return {
|
||||||
neighborsWithCoords: [] as Array<NeighborInfo & { lat: number | null; lon: number | null }>,
|
neighborsWithCoords: [] as Array<NeighborInfo & { lat: number | null; lon: number | null }>,
|
||||||
sorted: [] as Array<NeighborInfo & { distance: string | null }>,
|
enriched: [] as Array<
|
||||||
|
NeighborInfo & { distance: string | null; distanceKm: number | null }
|
||||||
|
>,
|
||||||
hasDistances: false,
|
hasDistances: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const withCoords: Array<NeighborInfo & { lat: number | null; lon: number | null }> = [];
|
const withCoords: Array<NeighborInfo & { lat: number | null; lon: number | null }> = [];
|
||||||
const enriched: Array<NeighborInfo & { distance: string | null }> = [];
|
const list: Array<NeighborInfo & { distance: string | null; distanceKm: number | null }> = [];
|
||||||
let anyDist = false;
|
let anyDist = false;
|
||||||
|
|
||||||
for (const n of data.neighbors) {
|
for (const n of data.neighbors) {
|
||||||
@@ -92,29 +153,54 @@ export function NeighborsPane({
|
|||||||
const nLon = contact?.lon ?? null;
|
const nLon = contact?.lon ?? null;
|
||||||
|
|
||||||
let dist: string | null = null;
|
let dist: string | null = null;
|
||||||
|
let distKm: number | null = null;
|
||||||
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
|
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
|
||||||
const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
|
const km = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
|
||||||
if (distKm != null) {
|
if (km != null) {
|
||||||
dist = formatDistance(distKm, distanceUnit);
|
distKm = km;
|
||||||
|
dist = formatDistance(km, distanceUnit);
|
||||||
anyDist = true;
|
anyDist = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
enriched.push({ ...n, distance: dist });
|
list.push({ ...n, distance: dist, distanceKm: distKm });
|
||||||
|
|
||||||
if (isValidLocation(nLat, nLon)) {
|
if (isValidLocation(nLat, nLon)) {
|
||||||
withCoords.push({ ...n, lat: nLat, lon: nLon });
|
withCoords.push({ ...n, lat: nLat, lon: nLon });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enriched.sort((a, b) => b.snr - a.snr);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
neighborsWithCoords: withCoords,
|
neighborsWithCoords: withCoords,
|
||||||
sorted: enriched,
|
enriched: list,
|
||||||
hasDistances: anyDist,
|
hasDistances: anyDist,
|
||||||
};
|
};
|
||||||
}, [contacts, data, distanceUnit, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
|
}, [contacts, data, distanceUnit, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
return [...enriched].sort((a, b) => {
|
||||||
|
switch (sortField) {
|
||||||
|
case 'name': {
|
||||||
|
const an = (a.name || a.pubkey_prefix).toLowerCase();
|
||||||
|
const bn = (b.name || b.pubkey_prefix).toLowerCase();
|
||||||
|
return an.localeCompare(bn) * dir;
|
||||||
|
}
|
||||||
|
case 'distance': {
|
||||||
|
// Neighbors without a known distance always sort last, regardless of direction.
|
||||||
|
if (a.distanceKm == null && b.distanceKm == null) return 0;
|
||||||
|
if (a.distanceKm == null) return 1;
|
||||||
|
if (b.distanceKm == null) return -1;
|
||||||
|
return (a.distanceKm - b.distanceKm) * dir;
|
||||||
|
}
|
||||||
|
case 'last_heard':
|
||||||
|
return (a.last_heard_seconds - b.last_heard_seconds) * dir;
|
||||||
|
case 'snr':
|
||||||
|
default:
|
||||||
|
return (a.snr - b.snr) * dir;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [enriched, sortField, sortDir]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RepeaterPane
|
<RepeaterPane
|
||||||
title="Neighbors"
|
title="Neighbors"
|
||||||
@@ -135,10 +221,39 @@ export function NeighborsPane({
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-muted-foreground text-xs">
|
<tr className="text-left text-muted-foreground text-xs">
|
||||||
<th className="pb-1 font-medium">Name</th>
|
<SortableHeader
|
||||||
<th className="pb-1 font-medium text-right">SNR</th>
|
label="Name"
|
||||||
{hasDistances && <th className="pb-1 font-medium text-right">Dist</th>}
|
field="name"
|
||||||
<th className="pb-1 font-medium text-right">Last Heard</th>
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="SNR"
|
||||||
|
field="snr"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="text-right"
|
||||||
|
/>
|
||||||
|
{hasDistances && (
|
||||||
|
<SortableHeader
|
||||||
|
label="Dist"
|
||||||
|
field="distance"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="text-right"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SortableHeader
|
||||||
|
label="Last Heard"
|
||||||
|
field="last_heard"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="text-right"
|
||||||
|
/>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||||
import { RepeaterDashboard } from '../components/RepeaterDashboard';
|
import { RepeaterDashboard } from '../components/RepeaterDashboard';
|
||||||
import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard';
|
import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard';
|
||||||
import type { Contact, Conversation } from '../types';
|
import type { Contact, Conversation } from '../types';
|
||||||
@@ -409,6 +409,49 @@ describe('RepeaterDashboard', () => {
|
|||||||
expect(screen.getByText('Using advert position')).toBeInTheDocument();
|
expect(screen.getByText('Using advert position')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sorts the neighbors table when column headers are clicked', () => {
|
||||||
|
mockHook.loggedIn = true;
|
||||||
|
mockHook.paneData.neighbors = {
|
||||||
|
neighbors: [
|
||||||
|
{ pubkey_prefix: 'cccccccccccc', name: 'Mike', snr: 5.0, last_heard_seconds: 20 },
|
||||||
|
{ pubkey_prefix: 'dddddddddddd', name: 'Zeta', snr: 9.0, last_heard_seconds: 30 },
|
||||||
|
{ pubkey_prefix: 'eeeeeeeeeeee', name: 'Alpha', snr: 1.0, last_heard_seconds: 10 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockHook.paneStates.neighbors = {
|
||||||
|
loading: false,
|
||||||
|
attempt: 1,
|
||||||
|
error: null,
|
||||||
|
fetched_at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
|
|
||||||
|
// Scope to the Neighbors table; the dashboard renders multiple panes at once.
|
||||||
|
const table = screen.getByRole('columnheader', { name: /last heard/i }).closest('table')!;
|
||||||
|
// The first child of each name cell is the bare name text node (the prefix
|
||||||
|
// lives in a nested span), so this reads exactly the neighbor name.
|
||||||
|
const names = () =>
|
||||||
|
within(table)
|
||||||
|
.getAllByRole('row')
|
||||||
|
.slice(1)
|
||||||
|
.map((r) => r.querySelector('td')?.firstChild?.textContent ?? '');
|
||||||
|
const header = (re: RegExp) => within(table).getByRole('columnheader', { name: re });
|
||||||
|
|
||||||
|
// Default order is SNR descending (preserves the pre-sorting behavior).
|
||||||
|
expect(names()).toEqual(['Zeta', 'Mike', 'Alpha']);
|
||||||
|
|
||||||
|
// Name ascending, then toggle to descending on a second click.
|
||||||
|
fireEvent.click(header(/name/i));
|
||||||
|
expect(names()).toEqual(['Alpha', 'Mike', 'Zeta']);
|
||||||
|
fireEvent.click(header(/name/i));
|
||||||
|
expect(names()).toEqual(['Zeta', 'Mike', 'Alpha']);
|
||||||
|
|
||||||
|
// Last Heard ascending surfaces the most recently heard neighbor first.
|
||||||
|
fireEvent.click(header(/last heard/i));
|
||||||
|
expect(names()).toEqual(['Alpha', 'Mike', 'Zeta']);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows fetching state with attempt counter', () => {
|
it('shows fetching state with attempt counter', () => {
|
||||||
mockHook.loggedIn = true;
|
mockHook.loggedIn = true;
|
||||||
mockHook.paneStates.status = { loading: true, attempt: 2, error: null };
|
mockHook.paneStates.status = { loading: true, attempt: 2, error: null };
|
||||||
|
|||||||
Reference in New Issue
Block a user