Flatten issues board rendering

- keep the route controller at the top of the file
- split the board into small local components
- remove the dead close-event helper and keep refresh invalidation only
This commit is contained in:
Antti Kettunen 2026-05-03 12:56:06 +03:00
parent 39f56fe63f
commit a4a4c0f12d
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
2 changed files with 350 additions and 278 deletions

View file

@ -1,7 +1,6 @@
import type { IssueRecord, IssuesSearch, IssueSnapshot } from './-issues.types'; import type { IssueRecord, IssuesSearch, IssueSnapshot } from './-issues.types';
export const REFRESH_EVENT = 'ss:issues-refresh'; export const REFRESH_EVENT = 'ss:issues-refresh';
export const CLOSE_EVENT = 'ss:issues-close-detail';
export const DEFAULT_ISSUES_SEARCH = { export const DEFAULT_ISSUES_SEARCH = {
status: 'open', status: 'open',
@ -126,10 +125,6 @@ export function dispatchIssuesRefreshEvent() {
window.dispatchEvent(new CustomEvent(REFRESH_EVENT)); window.dispatchEvent(new CustomEvent(REFRESH_EVENT));
} }
export function dispatchIssuesCloseEvent() {
window.dispatchEvent(new CustomEvent(CLOSE_EVENT));
}
export function parseSnapshot(snapshot: IssueRecord['snapshot_data']): IssueSnapshot { export function parseSnapshot(snapshot: IssueRecord['snapshot_data']): IssueSnapshot {
if (!snapshot) { if (!snapshot) {
return {}; return {};

View file

@ -9,15 +9,14 @@ import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types';
import { issueCountsQueryOptions, issueListQueryOptions } from '../-issues.api'; import { issueCountsQueryOptions, issueListQueryOptions } from '../-issues.api';
import { import {
CLOSE_EVENT,
REFRESH_EVENT, REFRESH_EVENT,
dispatchIssuesRefreshEvent, dispatchIssuesRefreshEvent,
formatIssueDate,
getEntityDetails, getEntityDetails,
getEntityLabel, getEntityLabel,
getEntityName, getEntityName,
getIssueArtwork, getIssueArtwork,
getPriorityClassName, getPriorityClassName,
formatIssueDate,
ISSUE_CATEGORY_META, ISSUE_CATEGORY_META,
ISSUE_STATUS_META, ISSUE_STATUS_META,
normalizeIssuesSearch, normalizeIssuesSearch,
@ -29,18 +28,9 @@ import styles from './issues-page.module.css';
export function IssuesPage() { export function IssuesPage() {
useReactPageShell('issues'); useReactPageShell('issues');
const { isAdmin, profileId } = useProfile();
const queryClient = useQueryClient();
const navigate = useNavigate({ from: Route.fullPath }); const navigate = useNavigate({ from: Route.fullPath });
const params = Route.useSearch(); const params = Route.useSearch();
const openIssue = (issueId: number) => {
navigate({
to: Route.fullPath,
search: (prev) => normalizeIssuesSearch({ ...prev, issueId }),
});
};
const clearIssueSelection = () => { const clearIssueSelection = () => {
navigate({ navigate({
to: Route.fullPath, to: Route.fullPath,
@ -49,60 +39,9 @@ export function IssuesPage() {
}); });
}; };
useEffect(() => {
const handleRefresh = () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
};
window.addEventListener(REFRESH_EVENT, handleRefresh);
window.addEventListener(CLOSE_EVENT, clearIssueSelection);
return () => {
window.removeEventListener(REFRESH_EVENT, handleRefresh);
window.removeEventListener(CLOSE_EVENT, clearIssueSelection);
};
}, [navigate, queryClient]);
const countsQuery = useQuery({
...issueCountsQueryOptions(profileId),
});
const issuesQuery = useQuery({
...issueListQueryOptions(profileId, params),
});
return ( return (
<> <>
<IssueBoard <IssueBoard />
categoryFilter={params.category}
counts={countsQuery.data}
isAdmin={isAdmin}
issues={issuesQuery.data?.issues ?? []}
issuesError={issuesQuery.error}
issuesLoading={issuesQuery.isLoading}
onCategoryChange={(category) =>
navigate({
to: Route.fullPath,
search: (prev) =>
normalizeIssuesSearch({
...prev,
category,
}),
replace: true,
})
}
onIssueSelect={openIssue}
onStatusChange={(status) =>
navigate({
to: Route.fullPath,
search: (prev) =>
normalizeIssuesSearch({
...prev,
status,
}),
replace: true,
})
}
statusFilter={params.status}
/>
<IssueDetailModal <IssueDetailModal
issueId={params.issueId} issueId={params.issueId}
onClose={clearIssueSelection} onClose={clearIssueSelection}
@ -115,39 +54,98 @@ export function IssuesPage() {
); );
} }
function IssueBoard({ function IssueBoard() {
categoryFilter, const { isAdmin, profileId } = useProfile();
counts, const queryClient = useQueryClient();
isAdmin, const navigate = useNavigate({ from: Route.fullPath });
issues, const params = Route.useSearch();
issuesError,
issuesLoading, useEffect(() => {
onCategoryChange, const handleRefresh = () => {
onIssueSelect, queryClient.invalidateQueries({ queryKey: ['issues'] });
onStatusChange, };
statusFilter,
}: { window.addEventListener(REFRESH_EVENT, handleRefresh);
categoryFilter: string; return () => {
counts: IssueCounts | undefined; window.removeEventListener(REFRESH_EVENT, handleRefresh);
isAdmin: boolean; };
issues: IssueRecord[]; }, [queryClient]);
issuesError: unknown;
issuesLoading: boolean; const countsQuery = useQuery({
onCategoryChange: (category: string) => void; ...issueCountsQueryOptions(profileId),
onIssueSelect: (issueId: number) => void; });
onStatusChange: (status: IssueStatus | 'all') => void; const issuesQuery = useQuery({
statusFilter: IssueStatus | 'all'; ...issueListQueryOptions(profileId, params),
}) { });
const safeCounts = counts ?? {
open: 0, const openIssue = (issueId: number) => {
in_progress: 0, navigate({
resolved: 0, to: Route.fullPath,
dismissed: 0, search: (prev) => normalizeIssuesSearch({ ...prev, issueId }),
total: 0, });
};
const onCategoryChange = (category: string) => {
navigate({
to: Route.fullPath,
search: (prev) =>
normalizeIssuesSearch({
...prev,
category,
}),
replace: true,
});
};
const onStatusChange = (status: IssueStatus | 'all') => {
navigate({
to: Route.fullPath,
search: (prev) =>
normalizeIssuesSearch({
...prev,
status,
}),
replace: true,
});
}; };
return ( return (
<div className={styles.issuesContainer} data-testid="issues-board"> <div className={styles.issuesContainer} data-testid="issues-board">
<IssueBoardHeader
isAdmin={isAdmin}
category={params.category}
status={params.status}
onCategoryChange={onCategoryChange}
onStatusChange={onStatusChange}
/>
<IssueBoardStats counts={countsQuery.data ?? EMPTY_ISSUE_COUNTS} />
<IssueBoardList
categoryFilter={params.category}
issues={issuesQuery.data?.issues ?? []}
issuesError={issuesQuery.error}
issuesLoading={issuesQuery.isLoading}
showReporterName={isAdmin}
onIssueSelect={openIssue}
statusFilter={params.status}
/>
</div>
);
}
function IssueBoardHeader({
category,
isAdmin,
status,
onCategoryChange,
onStatusChange,
}: {
category: string;
isAdmin: boolean;
status: IssueStatus | 'all';
onCategoryChange: (category: string) => void;
onStatusChange: (status: IssueStatus | 'all') => void;
}) {
return (
<div className={styles.issuesHeader} id="issues-header"> <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>
@ -162,7 +160,7 @@ function IssueBoard({
<Select <Select
id="issues-filter-status" id="issues-filter-status"
aria-label="Status" aria-label="Status"
value={statusFilter} value={status}
onChange={(event) => onStatusChange(event.target.value as IssueStatus | 'all')} onChange={(event) => onStatusChange(event.target.value as IssueStatus | 'all')}
> >
<option value="open">Open</option> <option value="open">Open</option>
@ -174,7 +172,7 @@ function IssueBoard({
<Select <Select
id="issues-filter-category" id="issues-filter-category"
aria-label="Category" aria-label="Category"
value={categoryFilter} value={category}
onChange={(event) => onCategoryChange(event.target.value)} onChange={(event) => onCategoryChange(event.target.value)}
> >
<option value="all">All Categories</option> <option value="all">All Categories</option>
@ -198,44 +196,96 @@ function IssueBoard({
</div> </div>
</div> </div>
</div> </div>
);
}
function IssueBoardStats({ counts }: { counts: IssueCounts }) {
return (
<div className={styles.issuesStats} id="issues-stats" data-testid="issue-counts"> <div className={styles.issuesStats} id="issues-stats" data-testid="issue-counts">
<div className={`${styles.issuesStatCard} ${styles.issuesStatOpen}`}> <IssueStatCard className={styles.issuesStatOpen} label="Open" value={counts.open} />
<div className={styles.issuesStatNumber}>{safeCounts.open}</div> <IssueStatCard
<div className={styles.issuesStatLabel}>Open</div> className={styles.issuesStatProgress}
</div> label="In Progress"
<div className={`${styles.issuesStatCard} ${styles.issuesStatProgress}`}> value={counts.in_progress}
<div className={styles.issuesStatNumber}>{safeCounts.in_progress}</div> />
<div className={styles.issuesStatLabel}>In Progress</div> <IssueStatCard
</div> className={styles.issuesStatResolved}
<div className={`${styles.issuesStatCard} ${styles.issuesStatResolved}`}> label="Resolved"
<div className={styles.issuesStatNumber}>{safeCounts.resolved}</div> value={counts.resolved}
<div className={styles.issuesStatLabel}>Resolved</div> />
</div> <IssueStatCard
<div className={`${styles.issuesStatCard} ${styles.issuesStatDismissed}`}> className={styles.issuesStatDismissed}
<div className={styles.issuesStatNumber}>{safeCounts.dismissed}</div> label="Dismissed"
<div className={styles.issuesStatLabel}>Dismissed</div> value={counts.dismissed}
</div> />
<div className={`${styles.issuesStatCard} ${styles.issuesStatTotal}`}> <IssueStatCard className={styles.issuesStatTotal} label="Total" value={counts.total} />
<div className={styles.issuesStatNumber}>{safeCounts.total}</div>
<div className={styles.issuesStatLabel}>Total</div>
</div>
</div> </div>
);
function IssueStatCard({
className,
label,
value,
}: {
className: string;
label: string;
value: number;
}) {
return (
<div className={`${styles.issuesStatCard} ${className}`}>
<div className={styles.issuesStatNumber}>{value}</div>
<div className={styles.issuesStatLabel}>{label}</div>
</div>
);
}
}
function IssueBoardList({
categoryFilter,
issues,
issuesError,
issuesLoading,
onIssueSelect,
showReporterName,
statusFilter,
}: {
categoryFilter: string;
issues: IssueRecord[];
issuesError: unknown;
issuesLoading: boolean;
onIssueSelect: (issueId: number) => void;
showReporterName: boolean;
statusFilter: IssueStatus | 'all';
}) {
return (
<div className={styles.issuesList} id="issues-list" data-testid="issue-list"> <div className={styles.issuesList} id="issues-list" data-testid="issue-list">
{issuesLoading ? ( <IssueBoardListContent />
</div>
);
function IssueBoardListContent() {
if (issuesLoading) {
return (
<div className={styles.issuesLoading}> <div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} /> <div className={styles.issuesSpinner} />
Loading issues... Loading issues...
</div> </div>
) : issuesError ? ( );
}
if (issuesError) {
return (
<div className={styles.issuesEmpty}> <div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyTitle}>Failed to load issues</div> <div className={styles.issuesEmptyTitle}>Failed to load issues</div>
<div className={styles.issuesEmptyText}> <div className={styles.issuesEmptyText}>
{issuesError instanceof Error ? issuesError.message : 'Unknown error'} {issuesError instanceof Error ? issuesError.message : 'Unknown error'}
</div> </div>
</div> </div>
) : issues.length === 0 ? ( );
}
if (issues.length === 0) {
return (
<div className={styles.issuesEmpty}> <div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyIcon} aria-hidden="true"> <div className={styles.issuesEmptyIcon} aria-hidden="true">
🔍 🔍
@ -247,34 +297,41 @@ function IssueBoard({
: 'No issues have been reported yet'} : 'No issues have been reported yet'}
</div> </div>
</div> </div>
) : ( );
issues.map((issue) => { }
return issues.map((issue) => (
<IssueBoardCard
key={issue.id}
issue={issue}
showReporterName={showReporterName}
onIssueSelect={onIssueSelect}
/>
));
}
}
function IssueBoardCard({
issue,
showReporterName,
onIssueSelect,
}: {
issue: IssueRecord;
showReporterName: boolean;
onIssueSelect: (issueId: number) => void;
}) {
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);
const details = getEntityDetails(issue, snapshot); const details = getEntityDetails(issue, snapshot);
const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open; const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open;
const catMeta = ISSUE_CATEGORY_META[issue.category] || ISSUE_CATEGORY_META.other; const catMeta = ISSUE_CATEGORY_META[issue.category] || ISSUE_CATEGORY_META.other;
const priorityVariant = getPriorityClassName(issue.priority); const priorityClass = getIssuePriorityClassName(getPriorityClassName(issue.priority));
const statusClassName = const statusClassName = getIssueStatusClassName(issue.status);
issue.status === 'in_progress'
? styles.issueStatusProgress
: issue.status === 'resolved'
? styles.issueStatusResolved
: issue.status === 'dismissed'
? styles.issueStatusDismissed
: styles.issueStatusOpen;
const priorityClass =
priorityVariant === 'high'
? styles.issuePriorityHigh
: priorityVariant === 'low'
? styles.issuePriorityLow
: styles.issuePriorityNormal;
const createdDate = formatIssueDate(issue.created_at); const createdDate = formatIssueDate(issue.created_at);
return ( return (
<button <button
key={issue.id}
className={styles.issueCard} className={styles.issueCard}
type="button" type="button"
data-testid={`issue-card-${issue.id}`} data-testid={`issue-card-${issue.id}`}
@ -300,9 +357,7 @@ function IssueBoard({
) : null} ) : null}
</div> </div>
<div className={styles.issueCardEntity}> <div className={styles.issueCardEntity}>
<span className={styles.issueCardEntityType}> <span className={styles.issueCardEntityType}>{getEntityLabel(issue.entity_type)}</span>
{getEntityLabel(issue.entity_type)}
</span>
<span className={styles.issueCardEntityName}>{entityName}</span> <span className={styles.issueCardEntityName}>{entityName}</span>
{details.length > 0 ? ( {details.length > 0 ? (
<span className={styles.issueCardMetaLine}>{details.join(' - ')}</span> <span className={styles.issueCardMetaLine}>{details.join(' - ')}</span>
@ -313,15 +368,13 @@ function IssueBoard({
) : null} ) : null}
<div className={styles.issueCardFooter}> <div className={styles.issueCardFooter}>
<span className={styles.issueCardDate}>{createdDate}</span> <span className={styles.issueCardDate}>{createdDate}</span>
{isAdmin && issue.reporter_name ? ( {showReporterName && issue.reporter_name ? (
<span className={styles.issueCardProfile}>by {issue.reporter_name}</span> <span className={styles.issueCardProfile}>by {issue.reporter_name}</span>
) : null} ) : null}
</div> </div>
</div> </div>
<div className={styles.issueCardRight}> <div className={styles.issueCardRight}>
<span className={`${styles.issueStatusBadge} ${statusClassName}`}> <span className={`${styles.issueStatusBadge} ${statusClassName}`}>{statusMeta.label}</span>
{statusMeta.label}
</span>
<span <span
className={`${styles.issuePriorityDot} ${priorityClass}`} className={`${styles.issuePriorityDot} ${priorityClass}`}
title={`${issue.priority} priority`} title={`${issue.priority} priority`}
@ -329,9 +382,33 @@ function IssueBoard({
</div> </div>
</button> </button>
); );
}) }
)}
</div> const EMPTY_ISSUE_COUNTS: IssueCounts = {
</div> open: 0,
); in_progress: 0,
resolved: 0,
dismissed: 0,
total: 0,
};
const ISSUE_STATUS_CLASS_NAMES: Record<IssueRecord['status'], string> = {
open: styles.issueStatusOpen,
in_progress: styles.issueStatusProgress,
resolved: styles.issueStatusResolved,
dismissed: styles.issueStatusDismissed,
};
const ISSUE_PRIORITY_CLASS_NAMES: Record<'high' | 'low' | 'normal', string> = {
high: styles.issuePriorityHigh,
low: styles.issuePriorityLow,
normal: styles.issuePriorityNormal,
};
function getIssueStatusClassName(status: IssueRecord['status']): string {
return ISSUE_STATUS_CLASS_NAMES[status] || styles.issueStatusOpen;
}
function getIssuePriorityClassName(priority: 'high' | 'low' | 'normal'): string {
return ISSUE_PRIORITY_CLASS_NAMES[priority] || styles.issuePriorityNormal;
} }