mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
fix(tours): refresh the wanderer watch after a fixed token and truly disable its switch offline
The watch row only re-read its state when the connection came or went, so after a passing connection test or a newly saved token it kept saying "Token refused" until a reload. The connection card now hands it a key built from the saved connection and its last check, and the row re-reads the watch whenever that changes. Without a connection the switch was only dimmed: the button stayed in the tab order and was announced as active. ToggleSwitch gains a disabled prop that disables the button and marks it aria-disabled, and the reason stays reachable through a focusable wrapper carrying the tooltip.
This commit is contained in:
@@ -64,4 +64,26 @@ describe('ToggleSwitch', () => {
|
||||
rerender(<ToggleSwitch on={true} onToggle={() => {}} />);
|
||||
expect(button.querySelector('span')!.style.left).toBe('22px');
|
||||
});
|
||||
|
||||
it('FE-COMP-TOGGLESWITCH-009: a disabled switch is out of reach and says so', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggle = vi.fn();
|
||||
render(<ToggleSwitch on={false} onToggle={onToggle} disabled />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(button.style.cursor).toBe('not-allowed');
|
||||
await user.click(button);
|
||||
await user.tab();
|
||||
expect(onToggle).not.toHaveBeenCalled();
|
||||
expect(button).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-COMP-TOGGLESWITCH-010: an enabled switch keeps the pointer and no aria-disabled', () => {
|
||||
render(<ToggleSwitch on={false} onToggle={() => {}} />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeEnabled();
|
||||
expect(button).not.toHaveAttribute('aria-disabled');
|
||||
expect(button.style.cursor).toBe('pointer');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,16 +5,18 @@ const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
|
||||
|
||||
/**
|
||||
* `describedBy` names the element that explains the switch, for a switch whose label alone
|
||||
* does not say what flipping it changes.
|
||||
* does not say what flipping it changes. `disabled` takes it out of the tab order and tells
|
||||
* assistive tech it cannot act; the caller dims it and says why.
|
||||
*/
|
||||
export default function ToggleSwitch({ on, onToggle, label, describedBy }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string }) {
|
||||
export default function ToggleSwitch({ on, onToggle, label, describedBy, disabled = false }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string; disabled?: boolean }) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
|
||||
disabled={disabled} aria-disabled={disabled || undefined}
|
||||
// A keyboard focus ring in the text colour, like the fields; the pointer gets none.
|
||||
className="group outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||
borderRadius: 12, border: 'none', padding: 0, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||
boxShadow: 'inset 0 1px 2px color-mix(in srgb, var(--text-primary) 12%, transparent)',
|
||||
transition: `background 0.2s ${EASE}`,
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function WandererConnectionSection(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WandererWatchRow connected={S.connected} />
|
||||
<WandererWatchRow refreshKey={S.checkKey} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,20 +54,23 @@ beforeEach(() => {
|
||||
describe('WandererWatchRow (WAN-02)', () => {
|
||||
it('is off by default and dimmed, not hidden, without a connection', async () => {
|
||||
repo.getWatch.mockResolvedValue({ ...off, connected: false });
|
||||
render(<WandererWatchRow connected={false} />);
|
||||
render(<WandererWatchRow refreshKey="false||" />);
|
||||
await waitFor(() => expect(repo.getWatch).toHaveBeenCalled());
|
||||
const toggle = await screen.findByRole('button', { name: 'Check my trails for changes', pressed: false });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(toggle.closest('[aria-disabled="true"]')).not.toBeNull();
|
||||
expect(toggle).toBeDisabled();
|
||||
expect(toggle).toHaveAttribute('aria-disabled', 'true');
|
||||
await userEvent.tab();
|
||||
expect(toggle).not.toHaveFocus();
|
||||
expect(screen.queryByTestId('wanderer-watch-status')).toBeNull();
|
||||
});
|
||||
|
||||
it('switches on at once and then says when it last looked', async () => {
|
||||
repo.getWatch.mockResolvedValue(off);
|
||||
repo.setWatch.mockResolvedValue({ ...off, enabled: true });
|
||||
render(<WandererWatchRow connected />);
|
||||
render(<WandererWatchRow refreshKey="true||" />);
|
||||
const toggle = await screen.findByRole('button', { name: 'Check my trails for changes', pressed: false });
|
||||
await waitFor(() => expect(toggle.closest('[aria-disabled="true"]')).toBeNull());
|
||||
await waitFor(() => expect(toggle).toBeEnabled());
|
||||
await userEvent.click(toggle);
|
||||
expect(repo.setWatch).toHaveBeenCalledWith(true);
|
||||
expect(await screen.findByText('Not looked yet')).toBeInTheDocument();
|
||||
@@ -76,7 +79,7 @@ describe('WandererWatchRow (WAN-02)', () => {
|
||||
|
||||
it('counts the open hints on the button and opens each tour from the dialog', async () => {
|
||||
repo.getWatch.mockResolvedValue({ ...off, enabled: true, lastRunAt: '2026-10-20T10:00:00Z', hints });
|
||||
render(<WandererWatchRow connected />);
|
||||
render(<WandererWatchRow refreshKey="true||" />);
|
||||
expect(await screen.findByTestId('wanderer-hint-count')).toHaveTextContent('2');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Hints/ }));
|
||||
|
||||
@@ -95,7 +98,7 @@ describe('WandererWatchRow (WAN-02)', () => {
|
||||
|
||||
it('names a refused token instead of a last look', async () => {
|
||||
repo.getWatch.mockResolvedValue({ ...off, enabled: true, blocked: true });
|
||||
render(<WandererWatchRow connected />);
|
||||
render(<WandererWatchRow refreshKey="true||" />);
|
||||
expect(await screen.findByText('Token refused')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,12 +13,22 @@ import { useWandererWatch, type WandererWatchController } from './useWandererWat
|
||||
function WatchSwitch({ W }: { W: WandererWatchController }) {
|
||||
const { t } = useTranslation();
|
||||
const toggle = (
|
||||
<ToggleSwitch on={W.enabled} onToggle={() => void W.toggle()} label={t('wanderer.watch.label')} />
|
||||
<ToggleSwitch
|
||||
on={W.enabled}
|
||||
onToggle={() => void W.toggle()}
|
||||
label={t('wanderer.watch.label')}
|
||||
disabled={!W.available}
|
||||
/>
|
||||
);
|
||||
if (W.available) return toggle;
|
||||
// A disabled button takes no focus and, in some browsers, no hover either, so
|
||||
// the reason sits on a focusable wrapper the tooltip listens to.
|
||||
return (
|
||||
<Tooltip label={t('wanderer.watch.notConnected')}>
|
||||
<span className="inline-flex opacity-50" aria-disabled="true">
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="inline-flex cursor-not-allowed rounded-full opacity-50 outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
|
||||
>
|
||||
<span className="pointer-events-none inline-flex">{toggle}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -62,9 +72,9 @@ function HintsButton({ W }: { W: WandererWatchController }) {
|
||||
* own imported trails. Off until switched on; while on, what it last did and
|
||||
* its open hints, which open in a dialog that leads to each tour.
|
||||
*/
|
||||
export default function WandererWatchRow({ connected }: { connected: boolean }): React.ReactElement {
|
||||
export default function WandererWatchRow({ refreshKey }: { refreshKey: string }): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const W = useWandererWatch(connected);
|
||||
const W = useWandererWatch(refreshKey);
|
||||
return (
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
|
||||
@@ -129,6 +129,13 @@ export function useWandererConnection() {
|
||||
saved?.lastCheckedAt && saved.lastCheckOk === false && saved.lastCheckError
|
||||
? errorText(saved.lastCheckError)
|
||||
: null;
|
||||
/**
|
||||
* Changes whenever the server's view of the connection does: connecting,
|
||||
* disconnecting, a saved token (which clears the last check) or a test that
|
||||
* stored a new result. The watch row re-reads itself on it, since each of
|
||||
* those can lift or set its "token refused" state.
|
||||
*/
|
||||
const checkKey = `${connected}|${saved?.lastCheckedAt ?? ''}|${saved?.lastCheckOk ?? ''}`;
|
||||
|
||||
return {
|
||||
url,
|
||||
@@ -145,6 +152,7 @@ export function useWandererConnection() {
|
||||
tokenInvalid,
|
||||
canSave,
|
||||
lastCheck,
|
||||
checkKey,
|
||||
save,
|
||||
test,
|
||||
disconnect,
|
||||
|
||||
@@ -75,18 +75,32 @@ describe('watchStatusOf (WAN-02)', () => {
|
||||
|
||||
describe('useWandererWatch (WAN-02)', () => {
|
||||
it('loads with the card and again when the connection comes or goes', async () => {
|
||||
const { result, rerender } = renderHook(({ connected }) => useWandererWatch(connected), {
|
||||
initialProps: { connected: true },
|
||||
const { result, rerender } = renderHook(({ refreshKey }) => useWandererWatch(refreshKey), {
|
||||
initialProps: { refreshKey: 'true||' },
|
||||
});
|
||||
await waitFor(() => expect(result.current.available).toBe(true));
|
||||
expect(result.current.enabled).toBe(false);
|
||||
expect(result.current.status).toBeNull();
|
||||
rerender({ connected: false });
|
||||
rerender({ refreshKey: 'true||' });
|
||||
expect(repo.getWatch).toHaveBeenCalledTimes(1);
|
||||
rerender({ refreshKey: 'false||' });
|
||||
await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('drops a refused token once a connection test passes, without a reload', async () => {
|
||||
repo.getWatch.mockResolvedValueOnce({ ...off, enabled: true, blocked: true, pausedUntil: '2999-01-01T00:00:00Z' });
|
||||
repo.getWatch.mockResolvedValueOnce({ ...off, enabled: true, lastRunAt: '2026-10-20T10:00:00Z' });
|
||||
const { result, rerender } = renderHook(({ refreshKey }) => useWandererWatch(refreshKey), {
|
||||
initialProps: { refreshKey: 'true|2026-10-20T09:00:00Z|false' },
|
||||
});
|
||||
await waitFor(() => expect(result.current.status?.text).toBe('wanderer.watch.blocked'));
|
||||
rerender({ refreshKey: 'true|2026-10-20T11:00:00Z|true' });
|
||||
await waitFor(() => expect(result.current.status?.tone).toBe('neutral'));
|
||||
expect(result.current.status?.text).toMatch(/^wanderer\.watch\.lastRun/);
|
||||
});
|
||||
|
||||
it('switches at once, and says so either way', async () => {
|
||||
const { result } = renderHook(() => useWandererWatch(true));
|
||||
const { result } = renderHook(() => useWandererWatch('true||'));
|
||||
await waitFor(() => expect(result.current.available).toBe(true));
|
||||
await act(async () => {
|
||||
await result.current.toggle();
|
||||
@@ -105,7 +119,7 @@ describe('useWandererWatch (WAN-02)', () => {
|
||||
|
||||
it('leaves the switch alone without a connection, or when the watch could not be read', async () => {
|
||||
repo.getWatch.mockResolvedValueOnce({ ...off, connected: false });
|
||||
const { result } = renderHook(() => useWandererWatch(false));
|
||||
const { result } = renderHook(() => useWandererWatch('false||'));
|
||||
await waitFor(() => expect(repo.getWatch).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
await result.current.toggle();
|
||||
@@ -113,14 +127,14 @@ describe('useWandererWatch (WAN-02)', () => {
|
||||
expect(repo.setWatch).not.toHaveBeenCalled();
|
||||
|
||||
repo.getWatch.mockRejectedValueOnce(new Error('offline'));
|
||||
const failed = renderHook(() => useWandererWatch(true));
|
||||
const failed = renderHook(() => useWandererWatch('true||'));
|
||||
await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2));
|
||||
expect(failed.result.current.available).toBe(false);
|
||||
});
|
||||
|
||||
it('lists the hints and opens the tour one is about', async () => {
|
||||
repo.getWatch.mockResolvedValue({ ...off, enabled: true, hints: [hint] });
|
||||
const { result } = renderHook(() => useWandererWatch(true));
|
||||
const { result } = renderHook(() => useWandererWatch('true||'));
|
||||
await waitFor(() => expect(result.current.hints).toEqual([hint]));
|
||||
act(() => result.current.openHints());
|
||||
expect(result.current.hintsOpen).toBe(true);
|
||||
|
||||
@@ -43,12 +43,14 @@ export function watchStatusOf(
|
||||
|
||||
/**
|
||||
* WAN-02: the background watch in the wanderer card. It loads with the card
|
||||
* and again whenever the connection comes or goes, switches on and off at
|
||||
* and again whenever `refreshKey` changes, which the connection card bumps on
|
||||
* connecting, disconnecting, saving a token and a stored test result, so a
|
||||
* refused token clears once it is fixed. It switches on and off at
|
||||
* once (there is nothing to save with the form), and opens the tour a hint is
|
||||
* about. Online-only like the rest of the connection: the watch runs on the
|
||||
* server and its hints are read from there.
|
||||
*/
|
||||
export function useWandererWatch(connected: boolean) {
|
||||
export function useWandererWatch(refreshKey: string) {
|
||||
const { t, locale } = useTranslation();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
@@ -71,7 +73,7 @@ export function useWandererWatch(connected: boolean) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connected]);
|
||||
}, [refreshKey]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (!watch?.connected || saving) return;
|
||||
|
||||
Reference in New Issue
Block a user