mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
fix(client): give the desktop halves the same fixes their mobile twins got
Several of the fixes in this branch landed on a mobile screen while the desktop component with the identical bug sat in another package and was left alone, which leaves the two disagreeing about the same handler. So: the appearance tab flushes its debounced write on unmount, the journey share copy guards navigator.clipboard (undefined on a plain-HTTP self-hosted install), the admin collab toggle rolls back from a scoped snapshot instead of a stale one, the reservation modal parses metadata through the guarded helper rather than a bare JSON.parse that takes the route down, and the notification toggle surfaces its failures and stops racing concurrent toggles. The Journey components reached the memories provider-status endpoint with a raw fetch(), past every apiClient interceptor; they go through a small memoriesApi now. And the server half of the OSM menu_url finding: the client renders it through safeHttpUrl, so place-enrichment should not be handing a javascript: value out in the first place. The share section hides itself when the server says the link is not this user's to manage, rather than offering a create button that 403s.
This commit is contained in:
@@ -298,4 +298,35 @@ describe('AppearanceSettingsTab – dashboard widgets', () => {
|
||||
PERSIST,
|
||||
);
|
||||
});
|
||||
|
||||
// The scheme is already on the DOM by then, so dropping the write would revert the
|
||||
// choice on the next load without ever telling the user.
|
||||
it('FE-COMP-APPEARANCE-023: leaving the tab before the debounce fires flushes the pending save once', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = seedAppearance();
|
||||
const { unmount } = render(<AppearanceSettingsTab />);
|
||||
|
||||
await user.click(screen.getByText('Indigo'));
|
||||
unmount();
|
||||
|
||||
const calls = updateSetting.mock.calls.filter(c => c[0] === 'appearance');
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][1]).toMatchObject({ schemeId: 'indigo' });
|
||||
|
||||
// The timer is cancelled, so nothing lands a second time.
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
expect(updateSetting.mock.calls.filter(c => c[0] === 'appearance')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-APPEARANCE-024: a debounce that already fired is not written again on unmount', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = seedAppearance();
|
||||
const { unmount } = render(<AppearanceSettingsTab />);
|
||||
|
||||
await user.click(screen.getByText('Teal'));
|
||||
await waitFor(() => expect(updateSetting.mock.calls.filter(c => c[0] === 'appearance')).toHaveLength(1), PERSIST);
|
||||
|
||||
unmount();
|
||||
expect(updateSetting.mock.calls.filter(c => c[0] === 'appearance')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
|
||||
const [cfg, setCfg] = useState<AppearanceConfig>(() => normalizeAppearance(settings.appearance))
|
||||
const persistTimer = useRef<number | undefined>(undefined)
|
||||
// What the pending timer would have written, so leaving the tab inside the
|
||||
// debounce window still saves instead of silently dropping the change.
|
||||
const pendingWrite = useRef<AppearanceConfig | null>(null)
|
||||
|
||||
// Re-sync when settings change elsewhere (e.g. server reconcile / another tab).
|
||||
useEffect(() => {
|
||||
@@ -89,8 +92,11 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
|
||||
// Flush any pending persist on unmount.
|
||||
useEffect(() => () => {
|
||||
if (persistTimer.current) window.clearTimeout(persistTimer.current)
|
||||
}, [])
|
||||
if (!persistTimer.current) return
|
||||
window.clearTimeout(persistTimer.current)
|
||||
// The component is gone, so a failure has nowhere to be shown.
|
||||
if (pendingWrite.current) updateSetting('appearance', pendingWrite.current).catch(() => {})
|
||||
}, [updateSetting])
|
||||
|
||||
const isDark =
|
||||
settings.dark_mode === true ||
|
||||
@@ -103,7 +109,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
setCfg(next)
|
||||
applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false })
|
||||
if (persistTimer.current) window.clearTimeout(persistTimer.current)
|
||||
pendingWrite.current = next
|
||||
persistTimer.current = window.setTimeout(() => {
|
||||
pendingWrite.current = null
|
||||
updateSetting('appearance', next).catch((e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : t('common.error'))
|
||||
)
|
||||
|
||||
@@ -755,6 +755,54 @@ describe('NotificationsTab', () => {
|
||||
expect(screen.getByText('Connection refused')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-030: a failing toggle surfaces the generic error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.put('/api/notifications/preferences', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
);
|
||||
render(<><NotificationsTab /><ToastContainer /></>);
|
||||
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
|
||||
|
||||
const toggles = await screen.findAllByRole('button');
|
||||
await user.click(toggles[0]);
|
||||
|
||||
expect(await screen.findByText('Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-031: a failing toggle leaves a later toggle alone', async () => {
|
||||
const user = userEvent.setup();
|
||||
let rejectEmail!: () => void;
|
||||
server.use(
|
||||
http.put('/api/notifications/preferences', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, Record<string, boolean>>;
|
||||
// The first write is the email flip; in-app is still untouched there.
|
||||
if (body.trip_invite.inapp === true) {
|
||||
return new Promise<Response>(resolve => {
|
||||
rejectEmail = () => resolve(HttpResponse.json({ error: 'nope' }, { status: 500 }) as unknown as Response);
|
||||
});
|
||||
}
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><NotificationsTab /><ToastContainer /></>);
|
||||
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
|
||||
|
||||
// Email is off in this matrix, in-app is on.
|
||||
const [email, inapp] = await screen.findAllByRole('button');
|
||||
await user.click(email);
|
||||
await waitFor(() => expect(screen.getAllByRole('button')[0]).toHaveAttribute('aria-pressed', 'true'));
|
||||
|
||||
// Flipped while the email write is still hanging.
|
||||
await user.click(inapp);
|
||||
await waitFor(() => expect(screen.getAllByRole('button')[1]).toHaveAttribute('aria-pressed', 'false'));
|
||||
|
||||
rejectEmail();
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole('button')[0]).toHaveAttribute('aria-pressed', 'false'));
|
||||
// The in-app toggle the user made meanwhile is not undone.
|
||||
expect(screen.getAllByRole('button')[1]).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -125,7 +125,13 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
try {
|
||||
await notificationsApi.updatePreferences(updated)
|
||||
} catch {
|
||||
setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m)
|
||||
// Only this cell rolls back — restoring the whole snapshot would also undo a
|
||||
// toggle the user made while this request was in flight.
|
||||
setMatrix(m => m ? {
|
||||
...m,
|
||||
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: current } },
|
||||
} : m)
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user