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.
This commit is contained in:
parent
a2495aaba7
commit
fac4e1ba1a
8 changed files with 268 additions and 50 deletions
35
webui/src/routes/issues/-issues.helpers.test.ts
Normal file
35
webui/src/routes/issues/-issues.helpers.test.ts
Normal file
|
|
@ -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 }> = {
|
export const ISSUE_STATUS_META: Record<string, { label: string; className: string }> = {
|
||||||
open: { label: 'Open', className: 'is-open' },
|
open: { label: 'Open', className: 'is-open' },
|
||||||
in_progress: { label: 'In Progress', className: 'is-progress' },
|
in_progress: { label: 'In Progress', className: 'is-progress' },
|
||||||
|
|
@ -113,9 +119,10 @@ export function createDefaultIssueTitle(category: string, entityName: string): s
|
||||||
return `${label}: ${entityName || 'Unknown'}`;
|
return `${label}: ${entityName || 'Unknown'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeIssuesSearch(search: IssuesSearch | undefined): Required<IssuesSearch> {
|
export function normalizeIssuesSearch(search: IssuesSearch | undefined): NormalizedIssuesSearch {
|
||||||
const status = search?.status;
|
const status = search?.status;
|
||||||
const category = search?.category;
|
const category = search?.category;
|
||||||
|
const issueId = search?.issueId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status:
|
status:
|
||||||
|
|
@ -126,7 +133,15 @@ export function normalizeIssuesSearch(search: IssuesSearch | undefined): Require
|
||||||
status === 'dismissed'
|
status === 'dismissed'
|
||||||
? status
|
? status
|
||||||
: DEFAULT_ISSUES_SEARCH.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(
|
export async function fetchIssueList(
|
||||||
profileId: number,
|
profileId: number,
|
||||||
search: Required<IssuesSearch>,
|
search: Pick<NormalizedIssuesSearch, 'status' | 'category'>,
|
||||||
): Promise<IssueListResponse> {
|
): Promise<IssueListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('limit', String(DEFAULT_LIMIT));
|
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({
|
return queryOptions({
|
||||||
queryKey: ['issues', 'list', profileId, search.status, search.category],
|
queryKey: ['issues', 'list', profileId, search.status, search.category],
|
||||||
queryFn: () => fetchIssueList(profileId, search),
|
queryFn: () => fetchIssueList(profileId, search),
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ export interface IssueCountsResponse {
|
||||||
|
|
||||||
export interface IssuesSearch {
|
export interface IssuesSearch {
|
||||||
category?: string;
|
category?: string;
|
||||||
|
issueId?: string | number;
|
||||||
status?: IssueStatus | 'all';
|
status?: IssueStatus | 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,16 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderIssuesRoute() {
|
function renderIssuesRoute(initialEntries = ['/issues']) {
|
||||||
const queryClient = createAppQueryClient();
|
const queryClient = createAppQueryClient();
|
||||||
const history = createMemoryHistory({ initialEntries: ['/issues'] });
|
const history = createMemoryHistory({ initialEntries });
|
||||||
const router = createAppRouter({ history, queryClient });
|
const router = createAppRouter({ history, queryClient });
|
||||||
|
|
||||||
return render(<AppRouterProvider router={router} queryClient={queryClient} />);
|
return {
|
||||||
|
history,
|
||||||
|
router,
|
||||||
|
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflowActions = {
|
const workflowActions = {
|
||||||
|
|
@ -136,6 +140,11 @@ describe('issues route', () => {
|
||||||
expect(await screen.findByTestId('issue-card-7')).toHaveTextContent('Bad tags');
|
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 () => {
|
it('stores filters in route search state', async () => {
|
||||||
renderIssuesRoute();
|
renderIssuesRoute();
|
||||||
const status = await screen.findByRole('combobox', { name: /status/i });
|
const status = await screen.findByRole('combobox', { name: /status/i });
|
||||||
|
|
@ -144,11 +153,40 @@ describe('issues route', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens and closes the detail modal', async () => {
|
it('opens and closes the detail modal', async () => {
|
||||||
renderIssuesRoute();
|
const { history } = renderIssuesRoute();
|
||||||
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
||||||
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #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 }));
|
fireEvent.click(screen.getByRole('button', { name: /close issue detail/i }));
|
||||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
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 () => {
|
it('invokes the shared workflow adapter for admin downloads', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useMutation } from '@tanstack/react-query';
|
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 { Button, FormField, TextArea } from '@/components/form';
|
||||||
import {
|
import {
|
||||||
|
|
@ -40,11 +40,88 @@ export function IssueDetailModal({
|
||||||
profileId: number;
|
profileId: number;
|
||||||
}) {
|
}) {
|
||||||
const [adminResponse, setAdminResponse] = useState('');
|
const [adminResponse, setAdminResponse] = useState('');
|
||||||
|
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);
|
||||||
|
const isOpen = Boolean(issue || isLoading || error);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAdminResponse(issue?.admin_response || '');
|
setAdminResponse(issue?.admin_response || '');
|
||||||
}, [issue?.admin_response, issue?.id]);
|
}, [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({
|
const updateMutation = useMutation({
|
||||||
mutationFn: async (payload: { issueId: number; status: string; adminResponse: string }) => {
|
mutationFn: async (payload: { issueId: number; status: string; adminResponse: string }) => {
|
||||||
await updateIssue(profileId, payload.issueId, {
|
await updateIssue(profileId, payload.issueId, {
|
||||||
|
|
@ -197,6 +274,8 @@ export function IssueDetailModal({
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`${styles.modal} ${styles.issueDetailModal}`}
|
className={`${styles.modal} ${styles.issueDetailModal}`}
|
||||||
|
ref={modalRef}
|
||||||
|
tabIndex={-1}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className={styles.modalHeader}>
|
<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) {
|
function getStatusClassName(status: string) {
|
||||||
if (status === 'in_progress') return styles.issueStatusProgress;
|
if (status === 'in_progress') return styles.issueStatusProgress;
|
||||||
if (status === 'resolved') return styles.issueStatusResolved;
|
if (status === 'resolved') return styles.issueStatusResolved;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { Select } from '@/components/form';
|
import { Select } from '@/components/form';
|
||||||
import { getShellProfileContext } from '@/platform/shell/bridge';
|
import { getShellProfileContext } from '@/platform/shell/bridge';
|
||||||
|
|
@ -30,24 +30,39 @@ import { Route } from '../route';
|
||||||
import { IssueDetailModal } from './issue-detail-modal';
|
import { IssueDetailModal } from './issue-detail-modal';
|
||||||
import styles from './issues-page.module.css';
|
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() {
|
export function IssuesPage() {
|
||||||
const bridge = useReactPageShell('issues');
|
const bridge = useReactPageShell('issues');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate({ from: Route.fullPath });
|
const navigate = useNavigate({ from: Route.fullPath });
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const normalizedSearch = normalizeIssuesSearch(search);
|
const normalizedSearch = normalizeIssuesSearch(search);
|
||||||
const [selectedIssueId, setSelectedIssueId] = useState<number | null>(null);
|
const selectedIssueId = normalizedSearch.issueId ? Number(normalizedSearch.issueId) : null;
|
||||||
|
|
||||||
const profile = getShellProfileContext(bridge);
|
const profile = getShellProfileContext(bridge);
|
||||||
const profileId = profile?.profileId ?? 0;
|
const profileId = profile?.profileId ?? 0;
|
||||||
|
|
||||||
|
const openIssue = (issueId: number) => {
|
||||||
|
void navigate({
|
||||||
|
search: (prev) => normalizeIssuesSearch({ ...prev, issueId }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['issues'] });
|
void queryClient.invalidateQueries({ queryKey: ['issues'] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setSelectedIssueId(null);
|
clearIssueSelection(navigate);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener(REFRESH_EVENT, handleRefresh);
|
window.addEventListener(REFRESH_EVENT, handleRefresh);
|
||||||
|
|
@ -56,7 +71,7 @@ export function IssuesPage() {
|
||||||
window.removeEventListener(REFRESH_EVENT, handleRefresh);
|
window.removeEventListener(REFRESH_EVENT, handleRefresh);
|
||||||
window.removeEventListener(CLOSE_EVENT, handleClose);
|
window.removeEventListener(CLOSE_EVENT, handleClose);
|
||||||
};
|
};
|
||||||
}, [queryClient]);
|
}, [navigate, queryClient]);
|
||||||
|
|
||||||
const countsQuery = useQuery({
|
const countsQuery = useQuery({
|
||||||
...issueCountsQueryOptions(profileId),
|
...issueCountsQueryOptions(profileId),
|
||||||
|
|
@ -86,14 +101,22 @@ export function IssuesPage() {
|
||||||
issuesLoading={issuesQuery.isLoading}
|
issuesLoading={issuesQuery.isLoading}
|
||||||
onCategoryChange={(category) =>
|
onCategoryChange={(category) =>
|
||||||
void navigate({
|
void navigate({
|
||||||
search: (prev) => normalizeIssuesSearch({ ...prev, category }),
|
search: (prev) =>
|
||||||
|
normalizeIssuesSearch({
|
||||||
|
...prev,
|
||||||
|
category,
|
||||||
|
}),
|
||||||
replace: true,
|
replace: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onIssueSelect={setSelectedIssueId}
|
onIssueSelect={openIssue}
|
||||||
onStatusChange={(status) =>
|
onStatusChange={(status) =>
|
||||||
void navigate({
|
void navigate({
|
||||||
search: (prev) => normalizeIssuesSearch({ ...prev, status }),
|
search: (prev) =>
|
||||||
|
normalizeIssuesSearch({
|
||||||
|
...prev,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
replace: true,
|
replace: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -104,9 +127,9 @@ export function IssuesPage() {
|
||||||
issue={selectedIssueQuery.data ?? null}
|
issue={selectedIssueQuery.data ?? null}
|
||||||
isLoading={selectedIssueQuery.isLoading}
|
isLoading={selectedIssueQuery.isLoading}
|
||||||
error={selectedIssueQuery.error}
|
error={selectedIssueQuery.error}
|
||||||
onClose={() => setSelectedIssueId(null)}
|
onClose={() => clearIssueSelection(navigate)}
|
||||||
onMutationSuccess={() => {
|
onMutationSuccess={() => {
|
||||||
setSelectedIssueId(null);
|
clearIssueSelection(navigate);
|
||||||
dispatchIssuesRefreshEvent();
|
dispatchIssuesRefreshEvent();
|
||||||
}}
|
}}
|
||||||
profileId={profile.profileId}
|
profileId={profile.profileId}
|
||||||
|
|
@ -148,7 +171,7 @@ function IssueBoard({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.issuesContainer} data-testid="issues-board">
|
<div className={styles.issuesContainer} data-testid="issues-board">
|
||||||
<div className={styles.issuesHeader}>
|
<div className={styles.issuesHeader} id="issues-header">
|
||||||
<div className={styles.issuesHeaderLeft}>
|
<div className={styles.issuesHeaderLeft}>
|
||||||
<h2 className={styles.issuesTitle}>Issues</h2>
|
<h2 className={styles.issuesTitle}>Issues</h2>
|
||||||
<p className={styles.issuesSubtitle} id="issues-subtitle">
|
<p className={styles.issuesSubtitle} id="issues-subtitle">
|
||||||
|
|
@ -201,7 +224,7 @@ function IssueBoard({
|
||||||
</div>
|
</div>
|
||||||
</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.issuesStatCard} ${styles.issuesStatOpen}`}>
|
||||||
<div className={styles.issuesStatNumber}>{safeCounts.open}</div>
|
<div className={styles.issuesStatNumber}>{safeCounts.open}</div>
|
||||||
<div className={styles.issuesStatLabel}>Open</div>
|
<div className={styles.issuesStatLabel}>Open</div>
|
||||||
|
|
@ -224,33 +247,33 @@ function IssueBoard({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{issuesLoading ? (
|
<div className={styles.issuesList} id="issues-list" data-testid="issue-list">
|
||||||
<div className={styles.issuesLoading}>
|
{issuesLoading ? (
|
||||||
<div className={styles.issuesSpinner} />
|
<div className={styles.issuesLoading}>
|
||||||
Loading issues...
|
<div className={styles.issuesSpinner} />
|
||||||
</div>
|
Loading issues...
|
||||||
) : 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>
|
) : issuesError ? (
|
||||||
) : issues.length === 0 ? (
|
<div className={styles.issuesEmpty}>
|
||||||
<div className={styles.issuesEmpty}>
|
<div className={styles.issuesEmptyTitle}>Failed to load issues</div>
|
||||||
<div className={styles.issuesEmptyIcon} aria-hidden="true">
|
<div className={styles.issuesEmptyText}>
|
||||||
🔍
|
{issuesError instanceof Error ? issuesError.message : 'Unknown error'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.issuesEmptyTitle}>No issues found</div>
|
) : issues.length === 0 ? (
|
||||||
<div className={styles.issuesEmptyText}>
|
<div className={styles.issuesEmpty}>
|
||||||
{statusFilter !== 'open' || categoryFilter !== 'all'
|
<div className={styles.issuesEmptyIcon} aria-hidden="true">
|
||||||
? 'Try adjusting your filters'
|
🔍
|
||||||
: 'No issues have been reported yet'}
|
</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>
|
) : (
|
||||||
) : (
|
issues.map((issue) => {
|
||||||
<div className={styles.issuesList} data-testid="issue-list">
|
|
||||||
{issues.map((issue) => {
|
|
||||||
const snapshot = parseSnapshot(issue.snapshot_data);
|
const snapshot = parseSnapshot(issue.snapshot_data);
|
||||||
const artwork = getIssueArtwork(snapshot);
|
const artwork = getIssueArtwork(snapshot);
|
||||||
const entityName = getEntityName(issue, snapshot);
|
const entityName = getEntityName(issue, snapshot);
|
||||||
|
|
@ -331,9 +354,9 @@ function IssueBoard({
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { getProfileHomePath, getShellProfileContext } from '@/platform/shell/bri
|
||||||
|
|
||||||
import {
|
import {
|
||||||
issueCountsQueryOptions,
|
issueCountsQueryOptions,
|
||||||
|
issueDetailQueryOptions,
|
||||||
issueListQueryOptions,
|
issueListQueryOptions,
|
||||||
normalizeIssuesSearch,
|
normalizeIssuesSearch,
|
||||||
} from './-issues.helpers';
|
} from './-issues.helpers';
|
||||||
|
|
@ -17,7 +18,11 @@ export const Route = createFileRoute('/issues')({
|
||||||
throw redirect({ href: getProfileHomePath(bridge), replace: true });
|
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 }) => {
|
loader: async ({ context, deps }) => {
|
||||||
const profile = getShellProfileContext(context.platform.getShellBridge());
|
const profile = getShellProfileContext(context.platform.getShellBridge());
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
|
|
@ -25,6 +30,9 @@ export const Route = createFileRoute('/issues')({
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
context.queryClient.ensureQueryData(issueCountsQueryOptions(profile.profileId)),
|
context.queryClient.ensureQueryData(issueCountsQueryOptions(profile.profileId)),
|
||||||
context.queryClient.ensureQueryData(issueListQueryOptions(profile.profileId, deps)),
|
context.queryClient.ensureQueryData(issueListQueryOptions(profile.profileId, deps)),
|
||||||
|
deps.issueId
|
||||||
|
? context.queryClient.ensureQueryData(issueDetailQueryOptions(profile.profileId, deps.issueId))
|
||||||
|
: Promise.resolve(),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
component: IssuesPage,
|
component: IssuesPage,
|
||||||
|
|
|
||||||
|
|
@ -1178,14 +1178,15 @@ async function loadInitialData() {
|
||||||
: homePage;
|
: homePage;
|
||||||
|
|
||||||
// Always apply the target page to the legacy shell chrome.
|
// 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 router = getWebRouter();
|
||||||
const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage);
|
const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage);
|
||||||
|
|
||||||
if (route?.kind === 'react') {
|
if (route?.kind === 'react') {
|
||||||
showReactHost(targetPage);
|
showReactHost(targetPage);
|
||||||
setActivePageChrome(targetPage);
|
setActivePageChrome(targetPage);
|
||||||
|
if (window.location.pathname !== route.path) {
|
||||||
|
history.replaceState({ page: targetPage }, '', route.path);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue