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';
|
||||
|
||||
export const REFRESH_EVENT = 'ss:issues-refresh';
|
||||
export const CLOSE_EVENT = 'ss:issues-close-detail';
|
||||
|
||||
export const DEFAULT_ISSUES_SEARCH = {
|
||||
status: 'open',
|
||||
|
|
@ -126,10 +125,6 @@ export function dispatchIssuesRefreshEvent() {
|
|||
window.dispatchEvent(new CustomEvent(REFRESH_EVENT));
|
||||
}
|
||||
|
||||
export function dispatchIssuesCloseEvent() {
|
||||
window.dispatchEvent(new CustomEvent(CLOSE_EVENT));
|
||||
}
|
||||
|
||||
export function parseSnapshot(snapshot: IssueRecord['snapshot_data']): IssueSnapshot {
|
||||
if (!snapshot) {
|
||||
return {};
|
||||
|
|
|
|||
|
|
@ -9,15 +9,14 @@ import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types';
|
|||
|
||||
import { issueCountsQueryOptions, issueListQueryOptions } from '../-issues.api';
|
||||
import {
|
||||
CLOSE_EVENT,
|
||||
REFRESH_EVENT,
|
||||
dispatchIssuesRefreshEvent,
|
||||
formatIssueDate,
|
||||
getEntityDetails,
|
||||
getEntityLabel,
|
||||
getEntityName,
|
||||
getIssueArtwork,
|
||||
getPriorityClassName,
|
||||
formatIssueDate,
|
||||
ISSUE_CATEGORY_META,
|
||||
ISSUE_STATUS_META,
|
||||
normalizeIssuesSearch,
|
||||
|
|
@ -29,18 +28,9 @@ import styles from './issues-page.module.css';
|
|||
|
||||
export function IssuesPage() {
|
||||
useReactPageShell('issues');
|
||||
const { isAdmin, profileId } = useProfile();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
const params = Route.useSearch();
|
||||
|
||||
const openIssue = (issueId: number) => {
|
||||
navigate({
|
||||
to: Route.fullPath,
|
||||
search: (prev) => normalizeIssuesSearch({ ...prev, issueId }),
|
||||
});
|
||||
};
|
||||
|
||||
const clearIssueSelection = () => {
|
||||
navigate({
|
||||
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 (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
<IssueBoard />
|
||||
<IssueDetailModal
|
||||
issueId={params.issueId}
|
||||
onClose={clearIssueSelection}
|
||||
|
|
@ -115,223 +54,361 @@ export function IssuesPage() {
|
|||
);
|
||||
}
|
||||
|
||||
function IssueBoard({
|
||||
categoryFilter,
|
||||
counts,
|
||||
isAdmin,
|
||||
issues,
|
||||
issuesError,
|
||||
issuesLoading,
|
||||
onCategoryChange,
|
||||
onIssueSelect,
|
||||
onStatusChange,
|
||||
statusFilter,
|
||||
}: {
|
||||
categoryFilter: string;
|
||||
counts: IssueCounts | undefined;
|
||||
isAdmin: boolean;
|
||||
issues: IssueRecord[];
|
||||
issuesError: unknown;
|
||||
issuesLoading: boolean;
|
||||
onCategoryChange: (category: string) => void;
|
||||
onIssueSelect: (issueId: number) => void;
|
||||
onStatusChange: (status: IssueStatus | 'all') => void;
|
||||
statusFilter: IssueStatus | 'all';
|
||||
}) {
|
||||
const safeCounts = counts ?? {
|
||||
open: 0,
|
||||
in_progress: 0,
|
||||
resolved: 0,
|
||||
dismissed: 0,
|
||||
total: 0,
|
||||
function IssueBoard() {
|
||||
const { isAdmin, profileId } = useProfile();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
const params = Route.useSearch();
|
||||
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['issues'] });
|
||||
};
|
||||
|
||||
window.addEventListener(REFRESH_EVENT, handleRefresh);
|
||||
return () => {
|
||||
window.removeEventListener(REFRESH_EVENT, handleRefresh);
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
const countsQuery = useQuery({
|
||||
...issueCountsQueryOptions(profileId),
|
||||
});
|
||||
const issuesQuery = useQuery({
|
||||
...issueListQueryOptions(profileId, params),
|
||||
});
|
||||
|
||||
const openIssue = (issueId: number) => {
|
||||
navigate({
|
||||
to: Route.fullPath,
|
||||
search: (prev) => normalizeIssuesSearch({ ...prev, issueId }),
|
||||
});
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={styles.issuesContainer} data-testid="issues-board">
|
||||
<div className={styles.issuesHeader} id="issues-header">
|
||||
<div className={styles.issuesHeaderLeft}>
|
||||
<h2 className={styles.issuesTitle}>Issues</h2>
|
||||
<p className={styles.issuesSubtitle} id="issues-subtitle">
|
||||
{isAdmin
|
||||
? 'Manage and resolve reported library problems'
|
||||
: 'Track and resolve library problems'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.issuesHeaderRight}>
|
||||
<div className={styles.issuesFilters} id="issues-filters">
|
||||
<Select
|
||||
id="issues-filter-status"
|
||||
aria-label="Status"
|
||||
value={statusFilter}
|
||||
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={categoryFilter}
|
||||
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>
|
||||
<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.issuesHeaderLeft}>
|
||||
<h2 className={styles.issuesTitle}>Issues</h2>
|
||||
<p className={styles.issuesSubtitle} id="issues-subtitle">
|
||||
{isAdmin
|
||||
? 'Manage and resolve reported library problems'
|
||||
: 'Track and resolve library problems'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className={styles.issuesHeaderRight}>
|
||||
<div className={styles.issuesFilters} id="issues-filters">
|
||||
<Select
|
||||
id="issues-filter-status"
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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