mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Move routing override to modal
This commit is contained in:
175
frontend/src/components/ContactRoutingOverrideModal.tsx
Normal file
175
frontend/src/components/ContactRoutingOverrideModal.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../api';
|
||||
import type { Contact } from '../types';
|
||||
import {
|
||||
formatRouteLabel,
|
||||
formatRoutingOverrideInput,
|
||||
hasRoutingOverride,
|
||||
} from '../utils/pathUtils';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
|
||||
interface ContactRoutingOverrideModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
contact: Contact;
|
||||
onSaved: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
function summarizeLearnedRoute(contact: Contact): string {
|
||||
return formatRouteLabel(contact.last_path_len, true);
|
||||
}
|
||||
|
||||
function summarizeForcedRoute(contact: Contact): string | null {
|
||||
if (!hasRoutingOverride(contact)) {
|
||||
return null;
|
||||
}
|
||||
const routeOverrideLen = contact.route_override_len;
|
||||
return routeOverrideLen == null ? null : formatRouteLabel(routeOverrideLen, true);
|
||||
}
|
||||
|
||||
export function ContactRoutingOverrideModal({
|
||||
open,
|
||||
onClose,
|
||||
contact,
|
||||
onSaved,
|
||||
onError,
|
||||
}: ContactRoutingOverrideModalProps) {
|
||||
const [route, setRoute] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setRoute(formatRoutingOverrideInput(contact));
|
||||
setError(null);
|
||||
}, [contact, open]);
|
||||
|
||||
const forcedRouteSummary = useMemo(() => summarizeForcedRoute(contact), [contact]);
|
||||
|
||||
const saveRoute = async (value: string) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.setContactRoutingOverride(contact.public_key, value);
|
||||
onSaved(value.trim() === '' ? 'Routing override cleared' : 'Routing override updated');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update routing override';
|
||||
setError(message);
|
||||
onError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Routing Override</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set a forced route for this contact. Leave the field blank to clear the override and
|
||||
fall back to the learned route or flood until a new path is heard.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void saveRoute(route);
|
||||
}}
|
||||
>
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
||||
<div className="font-medium">{contact.name || contact.public_key.slice(0, 12)}</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
Current learned route: {summarizeLearnedRoute(contact)}
|
||||
</div>
|
||||
{forcedRouteSummary && (
|
||||
<div className="mt-1 text-destructive">
|
||||
Current forced route: {forcedRouteSummary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="routing-override-input">Forced route</Label>
|
||||
<Input
|
||||
id="routing-override-input"
|
||||
value={route}
|
||||
onChange={(event) => setRoute(event.target.value)}
|
||||
placeholder='Examples: "ae,f1" or "ae92,f13e"'
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
/>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>Use comma-separated 1, 2, or 3 byte hop IDs for an explicit path.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => void saveRoute('-1')}
|
||||
disabled={saving}
|
||||
>
|
||||
Force Flood
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => void saveRoute('0')}
|
||||
disabled={saving}
|
||||
>
|
||||
Force Direct
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={saving || route.trim().length === 0}>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: `Force ${route.trim() === '' ? 'custom' : route.trim()} routing`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button type="button" variant="secondary" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void saveRoute('')}
|
||||
disabled={saving}
|
||||
>
|
||||
Clear override
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
isValidLocation,
|
||||
calculateDistance,
|
||||
formatDistance,
|
||||
formatRouteLabel,
|
||||
formatRoutingOverrideInput,
|
||||
getEffectiveContactRoute,
|
||||
} from '../utils/pathUtils';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import type { Contact } from '../types';
|
||||
import { ContactRoutingOverrideModal } from './ContactRoutingOverrideModal';
|
||||
|
||||
interface ContactStatusInfoProps {
|
||||
contact: Contact;
|
||||
@@ -25,28 +24,10 @@ interface ContactStatusInfoProps {
|
||||
* shared between ChatHeader and RepeaterDashboard.
|
||||
*/
|
||||
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
|
||||
const [routingModalOpen, setRoutingModalOpen] = useState(false);
|
||||
const parts: ReactNode[] = [];
|
||||
const effectiveRoute = getEffectiveContactRoute(contact);
|
||||
|
||||
const editRoutingOverride = () => {
|
||||
const route = window.prompt(
|
||||
'Enter explicit path as comma-separated 1, 2, or 3 byte hops (for example "ae,f1" or "ae92,f13e").\nEnter 0 to force direct always.\nEnter -1 to force flooding always.\nLeave blank to clear the override and reset to flood until a new path is heard.',
|
||||
formatRoutingOverrideInput(contact)
|
||||
);
|
||||
if (route === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.setContactRoutingOverride(contact.public_key, route).then(
|
||||
() =>
|
||||
toast.success(
|
||||
route.trim() === '' ? 'Routing override cleared' : 'Routing override updated'
|
||||
),
|
||||
(err: unknown) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update routing override')
|
||||
);
|
||||
};
|
||||
|
||||
if (contact.last_seen) {
|
||||
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
|
||||
}
|
||||
@@ -54,13 +35,13 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
|
||||
parts.push(
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
className="cursor-pointer underline underline-offset-2 decoration-muted-foreground/50 hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
editRoutingOverride();
|
||||
setRoutingModalOpen(true);
|
||||
}}
|
||||
title="Click to edit routing override"
|
||||
>
|
||||
@@ -101,15 +82,24 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
|
||||
(
|
||||
{parts.map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ', '}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
)
|
||||
</span>
|
||||
<>
|
||||
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
|
||||
(
|
||||
{parts.map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ', '}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
)
|
||||
</span>
|
||||
<ContactRoutingOverrideModal
|
||||
open={routingModalOpen}
|
||||
onClose={() => setRoutingModalOpen(false)}
|
||||
contact={contact}
|
||||
onSaved={(message) => toast.success(message)}
|
||||
onError={(message) => toast.error(message)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { RepeaterDashboard } from '../components/RepeaterDashboard';
|
||||
import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard';
|
||||
import type { Contact, Conversation, Favorite } from '../types';
|
||||
@@ -465,7 +465,7 @@ describe('RepeaterDashboard', () => {
|
||||
expect(screen.getByText('1 hop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('direct path is clickable with routing override title', () => {
|
||||
it('direct path is clickable, underlined, and marked as editable', () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
@@ -475,6 +475,7 @@ describe('RepeaterDashboard', () => {
|
||||
const directEl = screen.getByTitle('Click to edit routing override');
|
||||
expect(directEl).toBeInTheDocument();
|
||||
expect(directEl.textContent).toBe('direct');
|
||||
expect(directEl.className).toContain('underline');
|
||||
});
|
||||
|
||||
it('shows forced decorator when a routing override is active', () => {
|
||||
@@ -495,13 +496,11 @@ describe('RepeaterDashboard', () => {
|
||||
expect(screen.getByText('(forced)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking direct path opens prompt and updates routing override', async () => {
|
||||
it('clicking direct path opens modal and can force direct routing', async () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('0');
|
||||
|
||||
const { api } = await import('../api');
|
||||
const overrideSpy = vi.spyOn(api, 'setContactRoutingOverride').mockResolvedValue({
|
||||
status: 'ok',
|
||||
@@ -511,21 +510,21 @@ describe('RepeaterDashboard', () => {
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Click to edit routing override'));
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Force Direct' }));
|
||||
|
||||
expect(promptSpy).toHaveBeenCalled();
|
||||
expect(overrideSpy).toHaveBeenCalledWith(REPEATER_KEY, '0');
|
||||
await waitFor(() => {
|
||||
expect(overrideSpy).toHaveBeenCalledWith(REPEATER_KEY, '0');
|
||||
});
|
||||
|
||||
promptSpy.mockRestore();
|
||||
overrideSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('clicking path does not call API when prompt is cancelled', async () => {
|
||||
it('closing the routing override modal does not call the API', async () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue(null);
|
||||
|
||||
const { api } = await import('../api');
|
||||
const overrideSpy = vi.spyOn(api, 'setContactRoutingOverride').mockResolvedValue({
|
||||
status: 'ok',
|
||||
@@ -535,11 +534,11 @@ describe('RepeaterDashboard', () => {
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Click to edit routing override'));
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(promptSpy).toHaveBeenCalled();
|
||||
expect(overrideSpy).not.toHaveBeenCalled();
|
||||
|
||||
promptSpy.mockRestore();
|
||||
overrideSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user