Sync issues routing and shell URLs

- Move issue detail selection into route search so the modal is deep-linkable and back-button friendly.
- Normalize issue category and detail params before they reach the loader.
- Keep the legacy shell URL in sync for React-owned home pages.
- Preserve the legacy issues-tour hooks on the React issues page.
- Add Escape handling, focus trapping, and focus restore to the issue detail modal.
- Add route and helper coverage for the new search-state behavior.
pull/388/head
Antti Kettunen 2 weeks ago
parent a2495aaba7
commit fac4e1ba1a
No known key found for this signature in database
GPG Key ID: C6B2A3D250359BD7

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vite-plus/test';
import { normalizeIssuesSearch } from './-issues.helpers';
describe('normalizeIssuesSearch', () => {
it('falls back to all for unknown categories', () => {
expect(normalizeIssuesSearch({ category: 'not_real' })).toEqual({
status: 'open',
category: 'all',
});
});
it('preserves known categories', () => {
expect(normalizeIssuesSearch({ category: 'wrong_metadata' })).toEqual({
status: 'open',
category: 'wrong_metadata',
});
});
it('drops invalid issue ids', () => {
expect(normalizeIssuesSearch({ issueId: 'abc123' })).toEqual({
status: 'open',
category: 'all',
issueId: undefined,
});
});
it('normalizes numeric issue ids', () => {
expect(normalizeIssuesSearch({ issueId: '7' })).toEqual({
status: 'open',
category: 'all',
issueId: 7,
});
});
});

@ -89,6 +89,12 @@ export const ISSUE_CATEGORY_META: Record<
},
};
const ISSUE_CATEGORY_KEYS = new Set(Object.keys(ISSUE_CATEGORY_META));
export type NormalizedIssuesSearch = Required<Pick<IssuesSearch, 'status' | 'category'>> & {
issueId?: number;
};
export const ISSUE_STATUS_META: Record<string, { label: string; className: string }> = {
open: { label: 'Open', className: 'is-open' },
in_progress: { label: 'In Progress', className: 'is-progress' },
@ -113,9 +119,10 @@ export function createDefaultIssueTitle(category: string, entityName: string): s
return `${label}: ${entityName || 'Unknown'}`;
}
export function normalizeIssuesSearch(search: IssuesSearch | undefined): Required<IssuesSearch> {
export function normalizeIssuesSearch(search: IssuesSearch | undefined): NormalizedIssuesSearch {
const status = search?.status;
const category = search?.category;
const issueId = search?.issueId;
return {
status:
@ -126,7 +133,15 @@ export function normalizeIssuesSearch(search: IssuesSearch | undefined): Require
status === 'dismissed'
? status
: DEFAULT_ISSUES_SEARCH.status,
category: typeof category === 'string' && category.length > 0 ? category : 'all',
category: typeof category === 'string' && ISSUE_CATEGORY_KEYS.has(category)
? category
: 'all',
issueId:
(typeof issueId === 'number' && Number.isInteger(issueId) && issueId > 0)
? issueId
: (typeof issueId === 'string' && /^[1-9]\d*$/.test(issueId)
? Number(issueId)
: undefined),
};
}
@ -144,7 +159,7 @@ export async function fetchIssueCounts(profileId: number): Promise<IssueCounts>
export async function fetchIssueList(
profileId: number,
search: Required<IssuesSearch>,
search: Pick<NormalizedIssuesSearch, 'status' | 'category'>,
): Promise<IssueListResponse> {
const params = new URLSearchParams();
params.set('limit', String(DEFAULT_LIMIT));
@ -240,7 +255,10 @@ export function issueCountsQueryOptions(profileId: number) {
});
}
export function issueListQueryOptions(profileId: number, search: Required<IssuesSearch>) {
export function issueListQueryOptions(
profileId: number,
search: Pick<NormalizedIssuesSearch, 'status' | 'category'>,
) {
return queryOptions({
queryKey: ['issues', 'list', profileId, search.status, search.category],
queryFn: () => fetchIssueList(profileId, search),

@ -84,6 +84,7 @@ export interface IssueCountsResponse {
export interface IssuesSearch {
category?: string;
issueId?: string | number;
status?: IssueStatus | 'all';
}

@ -28,12 +28,16 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
};
}
function renderIssuesRoute() {
function renderIssuesRoute(initialEntries = ['/issues']) {
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/issues'] });
const history = createMemoryHistory({ initialEntries });
const router = createAppRouter({ history, queryClient });
return render(<AppRouterProvider router={router} queryClient={queryClient} />);
return {
history,
router,
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
};
}
const workflowActions = {
@ -136,6 +140,11 @@ describe('issues route', () => {
expect(await screen.findByTestId('issue-card-7')).toHaveTextContent('Bad tags');
});
it('loads the detail modal from the route search state', async () => {
renderIssuesRoute(['/issues?issueId=7']);
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
});
it('stores filters in route search state', async () => {
renderIssuesRoute();
const status = await screen.findByRole('combobox', { name: /status/i });
@ -144,11 +153,40 @@ describe('issues route', () => {
});
it('opens and closes the detail modal', async () => {
renderIssuesRoute();
const { history } = renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
await waitFor(() => expect(history.location.search).toContain('issueId=7'));
fireEvent.click(screen.getByRole('button', { name: /close issue detail/i }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await waitFor(() => expect(history.location.search).toBe('?status=open&category=all'));
});
it('closes the detail modal with Escape', async () => {
const { history } = renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
await waitFor(() => expect(history.location.search).toContain('issueId=7'));
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await waitFor(() => expect(history.location.search).toBe('?status=open&category=all'));
});
it('traps focus inside the detail modal', async () => {
renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
const closeButton = await screen.findByRole('button', { name: /close issue detail/i });
const deleteButton = await screen.findByRole('button', { name: /delete/i });
deleteButton.focus();
expect(deleteButton).toHaveFocus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(closeButton).toHaveFocus();
});
it('invokes the shared workflow adapter for admin downloads', async () => {

@ -1,5 +1,5 @@
import { useMutation } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button, FormField, TextArea } from '@/components/form';
import {
@ -40,11 +40,88 @@ export function IssueDetailModal({
profileId: number;
}) {
const [adminResponse, setAdminResponse] = useState('');
const modalRef = useRef<HTMLDivElement | null>(null);
const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);
const isOpen = Boolean(issue || isLoading || error);
useEffect(() => {
setAdminResponse(issue?.admin_response || '');
}, [issue?.admin_response, issue?.id]);
useEffect(() => {
if (!isOpen) {
return;
}
previouslyFocusedElementRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const focusModal = () => {
const modal = modalRef.current;
if (!modal) return;
const focusable = getFocusableElements(modal);
(focusable[0] || modal).focus();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose();
return;
}
if (event.key !== 'Tab') return;
const modal = modalRef.current;
if (!modal) return;
const focusable = getFocusableElements(modal);
if (focusable.length === 0) {
event.preventDefault();
modal.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const activeElement = document.activeElement;
if (event.shiftKey) {
if (activeElement === first || !modal.contains(activeElement)) {
event.preventDefault();
last.focus();
}
} else if (activeElement === last) {
event.preventDefault();
first.focus();
}
};
const onFocusIn = (event: FocusEvent) => {
const modal = modalRef.current;
if (!modal) return;
if (event.target instanceof Node && !modal.contains(event.target)) {
const focusable = getFocusableElements(modal);
(focusable[0] || modal).focus();
}
};
const raf = requestAnimationFrame(focusModal);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('focusin', onFocusIn);
return () => {
cancelAnimationFrame(raf);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('focusin', onFocusIn);
previouslyFocusedElementRef.current?.focus?.();
previouslyFocusedElementRef.current = null;
};
}, [isOpen, onClose]);
const updateMutation = useMutation({
mutationFn: async (payload: { issueId: number; status: string; adminResponse: string }) => {
await updateIssue(profileId, payload.issueId, {
@ -197,6 +274,8 @@ export function IssueDetailModal({
>
<div
className={`${styles.modal} ${styles.issueDetailModal}`}
ref={modalRef}
tabIndex={-1}
onClick={(event) => event.stopPropagation()}
>
<div className={styles.modalHeader}>
@ -524,6 +603,21 @@ export function IssueDetailModal({
);
}
function getFocusableElements(container: HTMLElement) {
return Array.from(
container.querySelectorAll<HTMLElement>(
[
'button:not([disabled])',
'[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(','),
),
).filter((element) => element.tabIndex >= 0);
}
function getStatusClassName(status: string) {
if (status === 'in_progress') return styles.issueStatusProgress;
if (status === 'resolved') return styles.issueStatusResolved;

@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { Select } from '@/components/form';
import { getShellProfileContext } from '@/platform/shell/bridge';
@ -30,24 +30,39 @@ import { Route } from '../route';
import { IssueDetailModal } from './issue-detail-modal';
import styles from './issues-page.module.css';
type NavigateFunction = ReturnType<typeof useNavigate>;
function clearIssueSelection(navigate: NavigateFunction) {
void navigate({
search: (prev) => normalizeIssuesSearch({ ...prev, issueId: undefined }),
replace: true,
});
}
export function IssuesPage() {
const bridge = useReactPageShell('issues');
const queryClient = useQueryClient();
const navigate = useNavigate({ from: Route.fullPath });
const search = Route.useSearch();
const normalizedSearch = normalizeIssuesSearch(search);
const [selectedIssueId, setSelectedIssueId] = useState<number | null>(null);
const selectedIssueId = normalizedSearch.issueId ? Number(normalizedSearch.issueId) : null;
const profile = getShellProfileContext(bridge);
const profileId = profile?.profileId ?? 0;
const openIssue = (issueId: number) => {
void navigate({
search: (prev) => normalizeIssuesSearch({ ...prev, issueId }),
});
};
useEffect(() => {
const handleRefresh = () => {
void queryClient.invalidateQueries({ queryKey: ['issues'] });
};
const handleClose = () => {
setSelectedIssueId(null);
clearIssueSelection(navigate);
};
window.addEventListener(REFRESH_EVENT, handleRefresh);
@ -56,7 +71,7 @@ export function IssuesPage() {
window.removeEventListener(REFRESH_EVENT, handleRefresh);
window.removeEventListener(CLOSE_EVENT, handleClose);
};
}, [queryClient]);
}, [navigate, queryClient]);
const countsQuery = useQuery({
...issueCountsQueryOptions(profileId),
@ -86,14 +101,22 @@ export function IssuesPage() {
issuesLoading={issuesQuery.isLoading}
onCategoryChange={(category) =>
void navigate({
search: (prev) => normalizeIssuesSearch({ ...prev, category }),
search: (prev) =>
normalizeIssuesSearch({
...prev,
category,
}),
replace: true,
})
}
onIssueSelect={setSelectedIssueId}
onIssueSelect={openIssue}
onStatusChange={(status) =>
void navigate({
search: (prev) => normalizeIssuesSearch({ ...prev, status }),
search: (prev) =>
normalizeIssuesSearch({
...prev,
status,
}),
replace: true,
})
}
@ -104,9 +127,9 @@ export function IssuesPage() {
issue={selectedIssueQuery.data ?? null}
isLoading={selectedIssueQuery.isLoading}
error={selectedIssueQuery.error}
onClose={() => setSelectedIssueId(null)}
onClose={() => clearIssueSelection(navigate)}
onMutationSuccess={() => {
setSelectedIssueId(null);
clearIssueSelection(navigate);
dispatchIssuesRefreshEvent();
}}
profileId={profile.profileId}
@ -148,7 +171,7 @@ function IssueBoard({
return (
<div className={styles.issuesContainer} data-testid="issues-board">
<div className={styles.issuesHeader}>
<div className={styles.issuesHeader} id="issues-header">
<div className={styles.issuesHeaderLeft}>
<h2 className={styles.issuesTitle}>Issues</h2>
<p className={styles.issuesSubtitle} id="issues-subtitle">
@ -201,7 +224,7 @@ function IssueBoard({
</div>
</div>
<div className={styles.issuesStats} data-testid="issue-counts">
<div className={styles.issuesStats} id="issues-stats" data-testid="issue-counts">
<div className={`${styles.issuesStatCard} ${styles.issuesStatOpen}`}>
<div className={styles.issuesStatNumber}>{safeCounts.open}</div>
<div className={styles.issuesStatLabel}>Open</div>
@ -224,33 +247,33 @@ function IssueBoard({
</div>
</div>
{issuesLoading ? (
<div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} />
Loading issues...
</div>
) : issuesError ? (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyTitle}>Failed to load issues</div>
<div className={styles.issuesEmptyText}>
{issuesError instanceof Error ? issuesError.message : 'Unknown error'}
<div className={styles.issuesList} id="issues-list" data-testid="issue-list">
{issuesLoading ? (
<div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} />
Loading issues...
</div>
</div>
) : issues.length === 0 ? (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyIcon} aria-hidden="true">
🔍
) : issuesError ? (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyTitle}>Failed to load issues</div>
<div className={styles.issuesEmptyText}>
{issuesError instanceof Error ? issuesError.message : 'Unknown error'}
</div>
</div>
<div className={styles.issuesEmptyTitle}>No issues found</div>
<div className={styles.issuesEmptyText}>
{statusFilter !== 'open' || categoryFilter !== 'all'
? 'Try adjusting your filters'
: 'No issues have been reported yet'}
) : issues.length === 0 ? (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyIcon} aria-hidden="true">
🔍
</div>
<div className={styles.issuesEmptyTitle}>No issues found</div>
<div className={styles.issuesEmptyText}>
{statusFilter !== 'open' || categoryFilter !== 'all'
? 'Try adjusting your filters'
: 'No issues have been reported yet'}
</div>
</div>
</div>
) : (
<div className={styles.issuesList} data-testid="issue-list">
{issues.map((issue) => {
) : (
issues.map((issue) => {
const snapshot = parseSnapshot(issue.snapshot_data);
const artwork = getIssueArtwork(snapshot);
const entityName = getEntityName(issue, snapshot);
@ -331,9 +354,9 @@ function IssueBoard({
</div>
</button>
);
})}
</div>
)}
})
)}
</div>
</div>
);
}

@ -4,6 +4,7 @@ import { getProfileHomePath, getShellProfileContext } from '@/platform/shell/bri
import {
issueCountsQueryOptions,
issueDetailQueryOptions,
issueListQueryOptions,
normalizeIssuesSearch,
} from './-issues.helpers';
@ -17,7 +18,11 @@ export const Route = createFileRoute('/issues')({
throw redirect({ href: getProfileHomePath(bridge), replace: true });
}
},
loaderDeps: ({ search }) => search,
loaderDeps: ({ search }) => ({
status: search.status,
category: search.category,
issueId: search.issueId ?? null,
}),
loader: async ({ context, deps }) => {
const profile = getShellProfileContext(context.platform.getShellBridge());
if (!profile) return;
@ -25,6 +30,9 @@ export const Route = createFileRoute('/issues')({
await Promise.all([
context.queryClient.ensureQueryData(issueCountsQueryOptions(profile.profileId)),
context.queryClient.ensureQueryData(issueListQueryOptions(profile.profileId, deps)),
deps.issueId
? context.queryClient.ensureQueryData(issueDetailQueryOptions(profile.profileId, deps.issueId))
: Promise.resolve(),
]);
},
component: IssuesPage,

@ -1178,14 +1178,15 @@ async function loadInitialData() {
: homePage;
// Always apply the target page to the legacy shell chrome.
// When the router is present, skipRouteChange keeps the URL stable
// while still syncing the active page/nav state for direct loads.
const router = getWebRouter();
const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage);
if (route?.kind === 'react') {
showReactHost(targetPage);
setActivePageChrome(targetPage);
if (window.location.pathname !== route.path) {
history.replaceState({ page: targetPage }, '', route.path);
}
return;
}

Loading…
Cancel
Save