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:
parent
39f56fe63f
commit
a4a4c0f12d
2 changed files with 350 additions and 278 deletions
|
|
@ -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 {};
|
||||||
|
|
|
||||||
|
|
@ -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,223 +54,361 @@ 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">
|
||||||
<div className={styles.issuesHeader} id="issues-header">
|
<IssueBoardHeader
|
||||||
<div className={styles.issuesHeaderLeft}>
|
isAdmin={isAdmin}
|
||||||
<h2 className={styles.issuesTitle}>Issues</h2>
|
category={params.category}
|
||||||
<p className={styles.issuesSubtitle} id="issues-subtitle">
|
status={params.status}
|
||||||
{isAdmin
|
onCategoryChange={onCategoryChange}
|
||||||
? 'Manage and resolve reported library problems'
|
onStatusChange={onStatusChange}
|
||||||
: 'Track and resolve library problems'}
|
/>
|
||||||
</p>
|
<IssueBoardStats counts={countsQuery.data ?? EMPTY_ISSUE_COUNTS} />
|
||||||
</div>
|
<IssueBoardList
|
||||||
<div className={styles.issuesHeaderRight}>
|
categoryFilter={params.category}
|
||||||
<div className={styles.issuesFilters} id="issues-filters">
|
issues={issuesQuery.data?.issues ?? []}
|
||||||
<Select
|
issuesError={issuesQuery.error}
|
||||||
id="issues-filter-status"
|
issuesLoading={issuesQuery.isLoading}
|
||||||
aria-label="Status"
|
showReporterName={isAdmin}
|
||||||
value={statusFilter}
|
onIssueSelect={openIssue}
|
||||||
onChange={(event) => onStatusChange(event.target.value as IssueStatus | 'all')}
|
statusFilter={params.status}
|
||||||
>
|
/>
|
||||||
<option value="open">Open</option>
|
</div>
|
||||||
<option value="all">All Statuses</option>
|
);
|
||||||
<option value="in_progress">In Progress</option>
|
}
|
||||||
<option value="resolved">Resolved</option>
|
|
||||||
<option value="dismissed">Dismissed</option>
|
function IssueBoardHeader({
|
||||||
</Select>
|
category,
|
||||||
<Select
|
isAdmin,
|
||||||
id="issues-filter-category"
|
status,
|
||||||
aria-label="Category"
|
onCategoryChange,
|
||||||
value={categoryFilter}
|
onStatusChange,
|
||||||
onChange={(event) => onCategoryChange(event.target.value)}
|
}: {
|
||||||
>
|
category: string;
|
||||||
<option value="all">All Categories</option>
|
isAdmin: boolean;
|
||||||
<optgroup label="Track Issues">
|
status: IssueStatus | 'all';
|
||||||
<option value="wrong_track">Wrong Track</option>
|
onCategoryChange: (category: string) => void;
|
||||||
<option value="wrong_artist">Wrong Artist</option>
|
onStatusChange: (status: IssueStatus | 'all') => void;
|
||||||
<option value="wrong_album">Wrong Album</option>
|
}) {
|
||||||
<option value="audio_quality">Audio Quality</option>
|
return (
|
||||||
</optgroup>
|
<div className={styles.issuesHeader} id="issues-header">
|
||||||
<optgroup label="Album Issues">
|
<div className={styles.issuesHeaderLeft}>
|
||||||
<option value="wrong_cover">Wrong Cover Art</option>
|
<h2 className={styles.issuesTitle}>Issues</h2>
|
||||||
<option value="duplicate_tracks">Duplicate Tracks</option>
|
<p className={styles.issuesSubtitle} id="issues-subtitle">
|
||||||
<option value="missing_tracks">Missing Tracks</option>
|
{isAdmin
|
||||||
<option value="incomplete_album">Incomplete Album</option>
|
? 'Manage and resolve reported library problems'
|
||||||
</optgroup>
|
: 'Track and resolve library problems'}
|
||||||
<optgroup label="Both">
|
</p>
|
||||||
<option value="wrong_metadata">Wrong Metadata</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</optgroup>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.issuesHeaderRight}>
|
||||||
<div className={styles.issuesStats} id="issues-stats" data-testid="issue-counts">
|
<div className={styles.issuesFilters} id="issues-filters">
|
||||||
<div className={`${styles.issuesStatCard} ${styles.issuesStatOpen}`}>
|
<Select
|
||||||
<div className={styles.issuesStatNumber}>{safeCounts.open}</div>
|
id="issues-filter-status"
|
||||||
<div className={styles.issuesStatLabel}>Open</div>
|
aria-label="Status"
|
||||||
|
value={status}
|
||||||
|
onChange={(event) => onStatusChange(event.target.value as IssueStatus | 'all')}
|
||||||
|
>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="resolved">Resolved</option>
|
||||||
|
<option value="dismissed">Dismissed</option>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
id="issues-filter-category"
|
||||||
|
aria-label="Category"
|
||||||
|
value={category}
|
||||||
|
onChange={(event) => onCategoryChange(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
<optgroup label="Track Issues">
|
||||||
|
<option value="wrong_track">Wrong Track</option>
|
||||||
|
<option value="wrong_artist">Wrong Artist</option>
|
||||||
|
<option value="wrong_album">Wrong Album</option>
|
||||||
|
<option value="audio_quality">Audio Quality</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Album Issues">
|
||||||
|
<option value="wrong_cover">Wrong Cover Art</option>
|
||||||
|
<option value="duplicate_tracks">Duplicate Tracks</option>
|
||||||
|
<option value="missing_tracks">Missing Tracks</option>
|
||||||
|
<option value="incomplete_album">Incomplete Album</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Both">
|
||||||
|
<option value="wrong_metadata">Wrong Metadata</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</optgroup>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${styles.issuesStatCard} ${styles.issuesStatProgress}`}>
|
|
||||||
<div className={styles.issuesStatNumber}>{safeCounts.in_progress}</div>
|
|
||||||
<div className={styles.issuesStatLabel}>In Progress</div>
|
|
||||||
</div>
|
|
||||||
<div className={`${styles.issuesStatCard} ${styles.issuesStatResolved}`}>
|
|
||||||
<div className={styles.issuesStatNumber}>{safeCounts.resolved}</div>
|
|
||||||
<div className={styles.issuesStatLabel}>Resolved</div>
|
|
||||||
</div>
|
|
||||||
<div className={`${styles.issuesStatCard} ${styles.issuesStatDismissed}`}>
|
|
||||||
<div className={styles.issuesStatNumber}>{safeCounts.dismissed}</div>
|
|
||||||
<div className={styles.issuesStatLabel}>Dismissed</div>
|
|
||||||
</div>
|
|
||||||
<div className={`${styles.issuesStatCard} ${styles.issuesStatTotal}`}>
|
|
||||||
<div className={styles.issuesStatNumber}>{safeCounts.total}</div>
|
|
||||||
<div className={styles.issuesStatLabel}>Total</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.issuesList} id="issues-list" data-testid="issue-list">
|
|
||||||
{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>
|
|
||||||
</div>
|
|
||||||
) : 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>
|
|
||||||
) : (
|
|
||||||
issues.map((issue) => {
|
|
||||||
const snapshot = parseSnapshot(issue.snapshot_data);
|
|
||||||
const artwork = getIssueArtwork(snapshot);
|
|
||||||
const entityName = getEntityName(issue, snapshot);
|
|
||||||
const details = getEntityDetails(issue, snapshot);
|
|
||||||
const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open;
|
|
||||||
const catMeta = ISSUE_CATEGORY_META[issue.category] || ISSUE_CATEGORY_META.other;
|
|
||||||
const priorityVariant = getPriorityClassName(issue.priority);
|
|
||||||
const statusClassName =
|
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={issue.id}
|
|
||||||
className={styles.issueCard}
|
|
||||||
type="button"
|
|
||||||
data-testid={`issue-card-${issue.id}`}
|
|
||||||
onClick={() => onIssueSelect(issue.id)}
|
|
||||||
>
|
|
||||||
<div className={styles.issueCardLeft}>
|
|
||||||
{artwork ? (
|
|
||||||
<img className={styles.issueCardThumb} src={artwork} alt="" />
|
|
||||||
) : (
|
|
||||||
<div className={styles.issueCardThumbPlaceholder}>{catMeta.icon}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.issueCardCenter}>
|
|
||||||
<div className={styles.issueCardTitleRow}>
|
|
||||||
<span className={styles.issueCardCategoryIcon} title={catMeta.label}>
|
|
||||||
{catMeta.icon}
|
|
||||||
</span>
|
|
||||||
<span className={styles.issueCardTitle}>{issue.title}</span>
|
|
||||||
{issue.admin_response ? (
|
|
||||||
<span className={styles.issueCardResponded} title="Admin has responded">
|
|
||||||
💬
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className={styles.issueCardEntity}>
|
|
||||||
<span className={styles.issueCardEntityType}>
|
|
||||||
{getEntityLabel(issue.entity_type)}
|
|
||||||
</span>
|
|
||||||
<span className={styles.issueCardEntityName}>{entityName}</span>
|
|
||||||
{details.length > 0 ? (
|
|
||||||
<span className={styles.issueCardMetaLine}>{details.join(' - ')}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{issue.description ? (
|
|
||||||
<div className={styles.issueCardDescription}>{issue.description}</div>
|
|
||||||
) : null}
|
|
||||||
<div className={styles.issueCardFooter}>
|
|
||||||
<span className={styles.issueCardDate}>{createdDate}</span>
|
|
||||||
{isAdmin && issue.reporter_name ? (
|
|
||||||
<span className={styles.issueCardProfile}>by {issue.reporter_name}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.issueCardRight}>
|
|
||||||
<span className={`${styles.issueStatusBadge} ${statusClassName}`}>
|
|
||||||
{statusMeta.label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`${styles.issuePriorityDot} ${priorityClass}`}
|
|
||||||
title={`${issue.priority} priority`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IssueBoardStats({ counts }: { counts: IssueCounts }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.issuesStats} id="issues-stats" data-testid="issue-counts">
|
||||||
|
<IssueStatCard className={styles.issuesStatOpen} label="Open" value={counts.open} />
|
||||||
|
<IssueStatCard
|
||||||
|
className={styles.issuesStatProgress}
|
||||||
|
label="In Progress"
|
||||||
|
value={counts.in_progress}
|
||||||
|
/>
|
||||||
|
<IssueStatCard
|
||||||
|
className={styles.issuesStatResolved}
|
||||||
|
label="Resolved"
|
||||||
|
value={counts.resolved}
|
||||||
|
/>
|
||||||
|
<IssueStatCard
|
||||||
|
className={styles.issuesStatDismissed}
|
||||||
|
label="Dismissed"
|
||||||
|
value={counts.dismissed}
|
||||||
|
/>
|
||||||
|
<IssueStatCard className={styles.issuesStatTotal} label="Total" value={counts.total} />
|
||||||
|
</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">
|
||||||
|
<IssueBoardListContent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function IssueBoardListContent() {
|
||||||
|
if (issuesLoading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.issuesLoading}>
|
||||||
|
<div className={styles.issuesSpinner} />
|
||||||
|
Loading issues...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issuesError) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 artwork = getIssueArtwork(snapshot);
|
||||||
|
const entityName = getEntityName(issue, snapshot);
|
||||||
|
const details = getEntityDetails(issue, snapshot);
|
||||||
|
const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open;
|
||||||
|
const catMeta = ISSUE_CATEGORY_META[issue.category] || ISSUE_CATEGORY_META.other;
|
||||||
|
const priorityClass = getIssuePriorityClassName(getPriorityClassName(issue.priority));
|
||||||
|
const statusClassName = getIssueStatusClassName(issue.status);
|
||||||
|
const createdDate = formatIssueDate(issue.created_at);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={styles.issueCard}
|
||||||
|
type="button"
|
||||||
|
data-testid={`issue-card-${issue.id}`}
|
||||||
|
onClick={() => onIssueSelect(issue.id)}
|
||||||
|
>
|
||||||
|
<div className={styles.issueCardLeft}>
|
||||||
|
{artwork ? (
|
||||||
|
<img className={styles.issueCardThumb} src={artwork} alt="" />
|
||||||
|
) : (
|
||||||
|
<div className={styles.issueCardThumbPlaceholder}>{catMeta.icon}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.issueCardCenter}>
|
||||||
|
<div className={styles.issueCardTitleRow}>
|
||||||
|
<span className={styles.issueCardCategoryIcon} title={catMeta.label}>
|
||||||
|
{catMeta.icon}
|
||||||
|
</span>
|
||||||
|
<span className={styles.issueCardTitle}>{issue.title}</span>
|
||||||
|
{issue.admin_response ? (
|
||||||
|
<span className={styles.issueCardResponded} title="Admin has responded">
|
||||||
|
💬
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.issueCardEntity}>
|
||||||
|
<span className={styles.issueCardEntityType}>{getEntityLabel(issue.entity_type)}</span>
|
||||||
|
<span className={styles.issueCardEntityName}>{entityName}</span>
|
||||||
|
{details.length > 0 ? (
|
||||||
|
<span className={styles.issueCardMetaLine}>{details.join(' - ')}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{issue.description ? (
|
||||||
|
<div className={styles.issueCardDescription}>{issue.description}</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.issueCardFooter}>
|
||||||
|
<span className={styles.issueCardDate}>{createdDate}</span>
|
||||||
|
{showReporterName && issue.reporter_name ? (
|
||||||
|
<span className={styles.issueCardProfile}>by {issue.reporter_name}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.issueCardRight}>
|
||||||
|
<span className={`${styles.issueStatusBadge} ${statusClassName}`}>{statusMeta.label}</span>
|
||||||
|
<span
|
||||||
|
className={`${styles.issuePriorityDot} ${priorityClass}`}
|
||||||
|
title={`${issue.priority} priority`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_ISSUE_COUNTS: IssueCounts = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue