Unify issues validation and metadata

- add Zod-backed search validation for issues
- derive issue enums and search types from shared value arrays
- replace hardcoded filter and priority lists with shared metadata
- keep private helpers at the bottom of the issues UI files
- tighten issue detail fallback labels to shared metadata
This commit is contained in:
Antti Kettunen 2026-05-03 14:48:23 +03:00
parent fb29e0179e
commit 6471b291fa
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
10 changed files with 213 additions and 158 deletions

View file

@ -13,7 +13,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"ky": "^2.0.2", "ky": "^2.0.2",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"zod": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
@ -2177,6 +2178,16 @@
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tanstack/router-generator/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@tanstack/router-plugin": { "node_modules/@tanstack/router-plugin": {
"version": "1.167.27", "version": "1.167.27",
"resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.167.27.tgz", "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.167.27.tgz",
@ -2233,6 +2244,16 @@
} }
} }
}, },
"node_modules/@tanstack/router-plugin/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@tanstack/router-utils": { "node_modules/@tanstack/router-utils": {
"version": "1.161.7", "version": "1.161.7",
"resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.161.7.tgz", "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.161.7.tgz",
@ -4995,10 +5016,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "4.4.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View file

@ -19,7 +19,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"ky": "^2.0.2", "ky": "^2.0.2",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"zod": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",

View file

@ -2,7 +2,6 @@ import { queryOptions } from '@tanstack/react-query';
import { apiClient, readJson } from '@/app/api-client'; import { apiClient, readJson } from '@/app/api-client';
import type { NormalizedIssuesSearch } from './-issues.helpers';
import type { import type {
CreateIssuePayload, CreateIssuePayload,
IssueCounts, IssueCounts,
@ -10,6 +9,7 @@ import type {
IssueDetailResponse, IssueDetailResponse,
IssueListResponse, IssueListResponse,
IssueRecord, IssueRecord,
IssuesSearch,
} from './-issues.types'; } from './-issues.types';
const DEFAULT_LIMIT = 100; const DEFAULT_LIMIT = 100;
@ -34,7 +34,7 @@ export async function fetchIssueCounts(profileId: number): Promise<IssueCounts>
export async function fetchIssueList( export async function fetchIssueList(
profileId: number, profileId: number,
search: Pick<NormalizedIssuesSearch, 'status' | 'category'>, search: Pick<IssuesSearch, '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));
@ -132,7 +132,7 @@ export function issueCountsQueryOptions(profileId: number) {
export function issueListQueryOptions( export function issueListQueryOptions(
profileId: number, profileId: number,
search: Pick<NormalizedIssuesSearch, 'status' | 'category'>, search: Pick<IssuesSearch, 'status' | 'category'>,
) { ) {
return queryOptions({ return queryOptions({
queryKey: ['issues', 'list', profileId, search.status, search.category], queryKey: ['issues', 'list', profileId, search.status, search.category],

View file

@ -1,24 +1,27 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { ISSUE_CATEGORY_META, normalizeIssuesSearch } from './-issues.helpers'; import { ISSUE_CATEGORY_META } from './-issues.helpers';
import { ISSUE_SEARCH_SCHEMA } from './-issues.types';
describe('normalizeIssuesSearch', () => { describe('ISSUE_SEARCH_SCHEMA', () => {
it('falls back to all for unknown categories', () => { it('falls back to all for unknown categories', () => {
expect(normalizeIssuesSearch({ category: 'not_real' })).toEqual({ expect(ISSUE_SEARCH_SCHEMA.parse({ category: 'not_real' })).toEqual({
status: 'open', status: 'open',
category: 'all', category: 'all',
issueId: undefined,
}); });
}); });
it('preserves known categories', () => { it('preserves known categories', () => {
expect(normalizeIssuesSearch({ category: 'wrong_metadata' })).toEqual({ expect(ISSUE_SEARCH_SCHEMA.parse({ category: 'wrong_metadata' })).toEqual({
status: 'open', status: 'open',
category: 'wrong_metadata', category: 'wrong_metadata',
issueId: undefined,
}); });
}); });
it('drops invalid issue ids', () => { it('drops invalid issue ids', () => {
expect(normalizeIssuesSearch({ issueId: 'abc123' })).toEqual({ expect(ISSUE_SEARCH_SCHEMA.parse({ issueId: 'abc123' })).toEqual({
status: 'open', status: 'open',
category: 'all', category: 'all',
issueId: undefined, issueId: undefined,
@ -26,7 +29,7 @@ describe('normalizeIssuesSearch', () => {
}); });
it('normalizes numeric issue ids', () => { it('normalizes numeric issue ids', () => {
expect(normalizeIssuesSearch({ issueId: '7' })).toEqual({ expect(ISSUE_SEARCH_SCHEMA.parse({ issueId: '7' })).toEqual({
status: 'open', status: 'open',
category: 'all', category: 'all',
issueId: 7, issueId: 7,

View file

@ -1,14 +1,15 @@
import type { IssueRecord, IssuesSearch, IssueSnapshot } from './-issues.types'; import {
type IssueCategory,
type IssueRecord,
type IssueSnapshot,
type IssuePriority,
type IssueStatus,
} from './-issues.types';
export const REFRESH_EVENT = 'ss:issues-refresh'; export const REFRESH_EVENT = 'ss:issues-refresh';
export const DEFAULT_ISSUES_SEARCH = {
status: 'open',
category: 'all',
} satisfies Required<Pick<IssuesSearch, 'status' | 'category'>>;
export const ISSUE_CATEGORY_META: Record< export const ISSUE_CATEGORY_META: Record<
string, IssueCategory,
{ label: string; icon: string; description: string; applies: Array<'track' | 'album' | 'artist'> } { label: string; icon: string; description: string; applies: Array<'track' | 'album' | 'artist'> }
> = { > = {
wrong_track: { wrong_track: {
@ -73,13 +74,7 @@ export const ISSUE_CATEGORY_META: Record<
}, },
}; };
const ISSUE_CATEGORY_KEYS = new Set(Object.keys(ISSUE_CATEGORY_META)); export const ISSUE_STATUS_META: Record<IssueStatus, { label: string; className: string }> = {
export type NormalizedIssuesSearch = Required<Pick<IssuesSearch, 'status' | 'category'>> & {
issueId?: number;
};
export const ISSUE_STATUS_META: Record<string, { label: string; className: string }> = {
open: { label: 'Open', className: 'is-open' }, open: { label: 'Open', className: 'is-open' },
in_progress: { label: 'In Progress', className: 'is-progress' }, in_progress: { label: 'In Progress', className: 'is-progress' },
resolved: { label: 'Resolved', className: 'is-resolved' }, resolved: { label: 'Resolved', className: 'is-resolved' },
@ -93,32 +88,16 @@ export function getIssueCategoriesForEntity(entityType: IssueRecord['entity_type
} }
export function createDefaultIssueTitle(category: string, entityName: string): string { export function createDefaultIssueTitle(category: string, entityName: string): string {
const label = ISSUE_CATEGORY_META[category]?.label || 'Issue'; const label = getIssueCategoryMeta(category)?.label || 'Issue';
return `${label}: ${entityName || 'Unknown'}`; return `${label}: ${entityName || 'Unknown'}`;
} }
export function normalizeIssuesSearch(search: IssuesSearch | undefined): NormalizedIssuesSearch { export function getIssueCategoryMeta(category: string) {
const status = search?.status; return ISSUE_CATEGORY_META[category as IssueCategory];
const category = search?.category; }
const issueId = search?.issueId;
return { export function getIssueStatusMeta(status: string) {
status: return ISSUE_STATUS_META[status as IssueStatus];
status === 'all' ||
status === 'open' ||
status === 'in_progress' ||
status === 'resolved' ||
status === 'dismissed'
? status
: DEFAULT_ISSUES_SEARCH.status,
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,
};
} }
export function dispatchIssuesRefreshEvent() { export function dispatchIssuesRefreshEvent() {
@ -179,10 +158,10 @@ export function formatIssueDate(value?: string): string {
} }
export function formatStatusLabel(status: string): string { export function formatStatusLabel(status: string): string {
return ISSUE_STATUS_META[status]?.label || status.replace(/_/g, ' '); return getIssueStatusMeta(status)?.label || status.replace(/_/g, ' ');
} }
export function getPriorityClassName(priority: string): 'high' | 'low' | 'normal' { export function getPriorityClassName(priority: string): IssuePriority {
if (priority === 'high') return 'high'; if (priority === 'high') return 'high';
if (priority === 'low') return 'low'; if (priority === 'low') return 'low';
return 'normal'; return 'normal';

View file

@ -1,6 +1,45 @@
export type IssueStatus = 'open' | 'in_progress' | 'resolved' | 'dismissed'; import { z } from 'zod';
export type IssueEntityType = 'track' | 'album' | 'artist';
export type IssuePriority = 'low' | 'normal' | 'high'; export const ISSUE_ENTITY_TYPE_VALUES = ['track', 'album', 'artist'] as const;
export type IssueEntityType = (typeof ISSUE_ENTITY_TYPE_VALUES)[number];
export const ISSUE_CATEGORY_VALUES = [
'wrong_track',
'wrong_metadata',
'wrong_cover',
'wrong_artist',
'duplicate_tracks',
'missing_tracks',
'audio_quality',
'wrong_album',
'incomplete_album',
'other',
] as const;
export type IssueCategory = (typeof ISSUE_CATEGORY_VALUES)[number];
export const ISSUE_STATUS_VALUES = ['open', 'in_progress', 'resolved', 'dismissed'] as const;
export type IssueStatus = (typeof ISSUE_STATUS_VALUES)[number];
export const ISSUE_PRIORITY_VALUES = ['low', 'normal', 'high'] as const;
export type IssuePriority = (typeof ISSUE_PRIORITY_VALUES)[number];
export const ISSUE_SEARCH_STATUS_VALUES = [
'open',
'all',
'in_progress',
'resolved',
'dismissed',
] as const;
export const ISSUE_SEARCH_CATEGORY_VALUES = ['all', ...ISSUE_CATEGORY_VALUES] as const;
export const ISSUE_SEARCH_SCHEMA = z.object({
status: z.enum(ISSUE_SEARCH_STATUS_VALUES).default('open').catch('open'),
category: z.enum(ISSUE_SEARCH_CATEGORY_VALUES).default('all').catch('all'),
issueId: z.coerce.number().int().positive().optional().catch(undefined),
});
export type IssuesSearch = z.infer<typeof ISSUE_SEARCH_SCHEMA>;
export interface IssueTrackRow extends Record<string, unknown> { export interface IssueTrackRow extends Record<string, unknown> {
bitrate?: string | number; bitrate?: string | number;
@ -101,12 +140,6 @@ export interface IssueCountsResponse {
error?: string; error?: string;
} }
export interface IssuesSearch {
category?: string;
issueId?: string | number;
status?: IssueStatus | 'all';
}
export interface CreateIssuePayload { export interface CreateIssuePayload {
entity_type: IssueEntityType; entity_type: IssueEntityType;
entity_id: string; entity_id: string;

View file

@ -17,6 +17,7 @@ import {
formatStatusLabel, formatStatusLabel,
getIssueArtwork, getIssueArtwork,
getPriorityClassName, getPriorityClassName,
getIssueCategoryMeta,
ISSUE_CATEGORY_META, ISSUE_CATEGORY_META,
parseSnapshot, parseSnapshot,
} from '../-issues.helpers'; } from '../-issues.helpers';
@ -178,15 +179,14 @@ export function IssueDetailModal({
const snapshot = issue ? parseSnapshot(issue.snapshot_data) : {}; const snapshot = issue ? parseSnapshot(issue.snapshot_data) : {};
const issueArtwork = getIssueArtwork(snapshot); const issueArtwork = getIssueArtwork(snapshot);
const issueCategoryMeta = issue ? getIssueCategoryMeta(issue.category) : undefined;
const issueCategoryLabel = issue const issueCategoryLabel = issue
? `${ISSUE_CATEGORY_META[issue.category]?.icon || ''} ${ ? `${issueCategoryMeta?.icon || ''} ${issueCategoryMeta?.label || ISSUE_CATEGORY_META.other.label}`.trim()
ISSUE_CATEGORY_META[issue.category]?.label || issue.category
}`.trim()
: ''; : '';
const externalLinks = getExternalLinks(snapshot); const externalLinks = getExternalLinks(snapshot);
const trackMetaItems = getTrackMetaItems(snapshot); const trackMetaItems = getTrackMetaItems(snapshot);
const trackRows = Array.isArray(snapshot.tracks) ? snapshot.tracks : []; const trackRows = Array.isArray(snapshot.tracks) ? snapshot.tracks : [];
const priorityClassName = issue ? getPriorityClassName(issue.priority) : 'normal'; const priorityLevel = issue ? getPriorityClassName(issue.priority) : 'normal';
const albumMetaParts = issue ? getAlbumMetaParts(issue, snapshot) : []; const albumMetaParts = issue ? getAlbumMetaParts(issue, snapshot) : [];
const genreTags = Array.isArray(snapshot.genres) ? snapshot.genres.slice(0, 5) : []; const genreTags = Array.isArray(snapshot.genres) ? snapshot.genres.slice(0, 5) : [];
const albumWorkflowInput = { const albumWorkflowInput = {
@ -234,7 +234,7 @@ export function IssueDetailModal({
<img className={styles.issueHeroAlbumArt} src={issueArtwork} alt="" /> <img className={styles.issueHeroAlbumArt} src={issueArtwork} alt="" />
) : ( ) : (
<div className={styles.issueHeroAlbumPlaceholder}> <div className={styles.issueHeroAlbumPlaceholder}>
{ISSUE_CATEGORY_META[issue.category]?.icon || ISSUE_CATEGORY_META.other.icon} {issueCategoryMeta?.icon || ISSUE_CATEGORY_META.other.icon}
</div> </div>
)} )}
</div> </div>
@ -298,7 +298,7 @@ export function IssueDetailModal({
<div className={styles.issueDetailInfoBar}> <div className={styles.issueDetailInfoBar}>
<div className={styles.issueDetailInfoLeft}> <div className={styles.issueDetailInfoLeft}>
<span <span
className={`${styles.issuePriorityDot} ${getPriorityDotClassName(priorityClassName)}`} className={`${styles.issuePriorityDot} ${getPriorityDotClassName(priorityLevel)}`}
/> />
<span className={`${styles.issueStatusBadge} ${getStatusClassName(issue.status)}`}> <span className={`${styles.issueStatusBadge} ${getStatusClassName(issue.status)}`}>
{formatStatusLabel(issue.status)} {formatStatusLabel(issue.status)}

View file

@ -1,6 +1,7 @@
import { useForm } from '@tanstack/react-form'; import { useForm } from '@tanstack/react-form';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { z } from 'zod';
import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog'; import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog';
import { import {
@ -17,7 +18,7 @@ import {
import { Show } from '@/components/primitives'; import { Show } from '@/components/primitives';
import { useProfile } from '@/platform/shell/route-controllers'; import { useProfile } from '@/platform/shell/route-controllers';
import type { IssuePriority, IssueReportPayload } from '../-issues.types'; import type { IssueReportPayload } from '../-issues.types';
import { createIssue, issueCountsQueryOptions } from '../-issues.api'; import { createIssue, issueCountsQueryOptions } from '../-issues.api';
import { import {
@ -26,24 +27,9 @@ import {
getIssueCategoriesForEntity, getIssueCategoriesForEntity,
getEntityLabel, getEntityLabel,
} from '../-issues.helpers'; } from '../-issues.helpers';
import { ISSUE_CATEGORY_VALUES, ISSUE_PRIORITY_VALUES } from '../-issues.types';
import styles from './issue-detail-modal.module.css'; import styles from './issue-detail-modal.module.css';
const ISSUE_DOMAIN_QUERY_KEY = ['issues'] as const;
interface ReportIssueFormValues {
category: string;
description: string;
priority: IssuePriority;
title: string;
}
const DEFAULT_REPORT_ISSUE_VALUES: ReportIssueFormValues = {
category: '',
description: '',
priority: 'normal',
title: '',
};
export function IssueDomainHost() { export function IssueDomainHost() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const profile = useProfile(); const profile = useProfile();
@ -287,7 +273,7 @@ function ReportIssueModal({
label="Priority" label="Priority"
> >
<OptionButtonGroup> <OptionButtonGroup>
{(['low', 'normal', 'high'] as const).map((priority) => ( {ISSUE_PRIORITY_VALUES.map((priority) => (
<OptionButton <OptionButton
key={priority} key={priority}
onClick={() => field.handleChange(priority)} onClick={() => field.handleChange(priority)}
@ -344,24 +330,53 @@ function ReportIssueModal({
); );
} }
function normalizeReportIssueFormValues(values: ReportIssueFormValues): ReportIssueFormValues { const ISSUE_DOMAIN_QUERY_KEY = ['issues'] as const;
return {
category: values.category, const DEFAULT_REPORT_ISSUE_VALUES: ReportIssueFormValues = {
description: values.description.trim(), category: '',
priority: values.priority, description: '',
title: values.title.trim(), priority: 'normal',
}; title: '',
};
const reportIssueFormBaseSchema = z.object({
category: z
.string()
.trim()
.pipe(z.enum(ISSUE_CATEGORY_VALUES, { error: 'Please select an issue category' })),
description: z.string().trim(),
priority: z.enum(ISSUE_PRIORITY_VALUES),
title: z.string().trim().min(1, 'Please provide a title for the issue'),
});
type ReportIssueFormValues = z.input<typeof reportIssueFormBaseSchema>;
type NormalizedReportIssueFormValues = z.output<typeof reportIssueFormBaseSchema>;
function notify(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') {
window.showToast?.(message, type);
}
function updateBadge(openCount: number) {
const badge = document.getElementById('issues-nav-badge');
if (!badge) return;
badge.textContent = String(openCount || 0);
badge.classList.toggle('hidden', !openCount);
}
function normalizeReportIssueFormValues(
values: ReportIssueFormValues,
): NormalizedReportIssueFormValues {
return reportIssueFormBaseSchema.parse(values);
} }
function validateReportIssueForm( function validateReportIssueForm(
profileId: number, profileId: number,
values: ReportIssueFormValues, values: ReportIssueFormValues,
): string | undefined { ): string | undefined {
const normalizedValues = normalizeReportIssueFormValues(values);
if (!profileId) return 'Profile is still loading'; if (!profileId) return 'Profile is still loading';
if (!normalizedValues.category) return 'Please select an issue category'; const result = reportIssueFormBaseSchema.safeParse(values);
if (!normalizedValues.title) return 'Please provide a title for the issue'; if (result.success) return undefined;
return undefined; return result.error.issues[0]?.message || 'Unable to submit this issue';
} }
function getReportIssueFormError(errors: Array<unknown>): string { function getReportIssueFormError(errors: Array<unknown>): string {
@ -374,14 +389,3 @@ function getReportIssueFormError(errors: Array<unknown>): string {
} }
return 'Unable to submit this issue'; return 'Unable to submit this issue';
} }
function notify(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') {
window.showToast?.(message, type);
}
function updateBadge(openCount: number) {
const badge = document.getElementById('issues-nav-badge');
if (!badge) return;
badge.textContent = String(openCount || 0);
badge.classList.toggle('hidden', !openCount);
}

View file

@ -6,7 +6,7 @@ import { Select } from '@/components/form';
import { Show } from '@/components/primitives'; import { Show } from '@/components/primitives';
import { useProfile, useReactPageShell } from '@/platform/shell/route-controllers'; import { useProfile, useReactPageShell } from '@/platform/shell/route-controllers';
import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types'; import type { IssueCounts, IssuePriority, IssueRecord, IssuesSearch } from '../-issues.types';
import { issueCountsQueryOptions, issueListQueryOptions } from '../-issues.api'; import { issueCountsQueryOptions, issueListQueryOptions } from '../-issues.api';
import { import {
@ -20,9 +20,11 @@ import {
getPriorityClassName, getPriorityClassName,
ISSUE_CATEGORY_META, ISSUE_CATEGORY_META,
ISSUE_STATUS_META, ISSUE_STATUS_META,
normalizeIssuesSearch, getIssueCategoryMeta,
getIssueStatusMeta,
parseSnapshot, parseSnapshot,
} from '../-issues.helpers'; } from '../-issues.helpers';
import { ISSUE_CATEGORY_VALUES, ISSUE_SEARCH_STATUS_VALUES } from '../-issues.types';
import { Route } from '../route'; 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';
@ -35,7 +37,7 @@ export function IssuesPage() {
const clearIssueSelection = () => { const clearIssueSelection = () => {
void navigate({ void navigate({
to: Route.fullPath, to: Route.fullPath,
search: (prev) => normalizeIssuesSearch({ ...prev, issueId: undefined }), search: (prev) => ({ ...prev, issueId: undefined }),
replace: true, replace: true,
}); });
}; };
@ -82,30 +84,22 @@ function IssueBoard() {
const openIssue = (issueId: number) => { const openIssue = (issueId: number) => {
void navigate({ void navigate({
to: Route.fullPath, to: Route.fullPath,
search: (prev) => normalizeIssuesSearch({ ...prev, issueId }), search: (prev) => ({ ...prev, issueId }),
}); });
}; };
const onCategoryChange = (category: string) => { const onCategoryChange = (category: IssuesSearch['category']) => {
void navigate({ void navigate({
to: Route.fullPath, to: Route.fullPath,
search: (prev) => search: (prev) => ({ ...prev, category }),
normalizeIssuesSearch({
...prev,
category,
}),
replace: true, replace: true,
}); });
}; };
const onStatusChange = (status: IssueStatus | 'all') => { const onStatusChange = (status: IssuesSearch['status']) => {
void navigate({ void navigate({
to: Route.fullPath, to: Route.fullPath,
search: (prev) => search: (prev) => ({ ...prev, status }),
normalizeIssuesSearch({
...prev,
status,
}),
replace: true, replace: true,
}); });
}; };
@ -140,11 +134,11 @@ function IssueBoardHeader({
onCategoryChange, onCategoryChange,
onStatusChange, onStatusChange,
}: { }: {
category: string; category: IssuesSearch['category'];
isAdmin: boolean; isAdmin: boolean;
status: IssueStatus | 'all'; status: IssuesSearch['status'];
onCategoryChange: (category: string) => void; onCategoryChange: (category: IssuesSearch['category']) => void;
onStatusChange: (status: IssueStatus | 'all') => void; onStatusChange: (status: IssuesSearch['status']) => void;
}) { }) {
return ( return (
<div className={styles.issuesHeader} id="issues-header"> <div className={styles.issuesHeader} id="issues-header">
@ -162,37 +156,30 @@ function IssueBoardHeader({
id="issues-filter-status" id="issues-filter-status"
aria-label="Status" aria-label="Status"
value={status} value={status}
onChange={(event) => onStatusChange(event.target.value as IssueStatus | 'all')} onChange={(event) => onStatusChange(event.target.value as IssuesSearch['status'])}
> >
<option value="open">Open</option> {ISSUE_SEARCH_STATUS_VALUES.map((option) => (
<option value="all">All Statuses</option> <option key={option} value={option}>
<option value="in_progress">In Progress</option> {getIssueStatusFilterLabel(option)}
<option value="resolved">Resolved</option> </option>
<option value="dismissed">Dismissed</option> ))}
</Select> </Select>
<Select <Select
id="issues-filter-category" id="issues-filter-category"
aria-label="Category" aria-label="Category"
value={category} value={category}
onChange={(event) => onCategoryChange(event.target.value)} onChange={(event) => onCategoryChange(event.target.value as IssuesSearch['category'])}
> >
<option value="all">All Categories</option> <option value="all">All Categories</option>
<optgroup label="Track Issues"> {ISSUE_CATEGORY_FILTER_GROUPS.map((group) => (
<option value="wrong_track">Wrong Track</option> <optgroup key={group.label} label={group.label}>
<option value="wrong_artist">Wrong Artist</option> {getIssueCategoryFilterOptions(group).map((option) => (
<option value="wrong_album">Wrong Album</option> <option key={option} value={option}>
<option value="audio_quality">Audio Quality</option> {ISSUE_CATEGORY_META[option].label}
</optgroup> </option>
<optgroup label="Album Issues"> ))}
<option value="wrong_cover">Wrong Cover Art</option> </optgroup>
<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> </Select>
</div> </div>
</div> </div>
@ -256,7 +243,7 @@ function IssueBoardList({
issuesLoading: boolean; issuesLoading: boolean;
onIssueSelect: (issueId: number) => void; onIssueSelect: (issueId: number) => void;
showReporterName: boolean; showReporterName: boolean;
statusFilter: IssueStatus | 'all'; statusFilter: IssuesSearch['status'];
}) { }) {
return ( return (
<div className={styles.issuesList} id="issues-list" data-testid="issue-list"> <div className={styles.issuesList} id="issues-list" data-testid="issue-list">
@ -325,8 +312,8 @@ function IssueBoardCard({
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 = getIssueStatusMeta(issue.status) || ISSUE_STATUS_META.open;
const catMeta = ISSUE_CATEGORY_META[issue.category] || ISSUE_CATEGORY_META.other; const catMeta = getIssueCategoryMeta(issue.category) || ISSUE_CATEGORY_META.other;
const priorityClass = getIssuePriorityClassName(getPriorityClassName(issue.priority)); const priorityClass = getIssuePriorityClassName(getPriorityClassName(issue.priority));
const statusClassName = getIssueStatusClassName(issue.status); const statusClassName = getIssueStatusClassName(issue.status);
const createdDate = formatIssueDate(issue.created_at); const createdDate = formatIssueDate(issue.created_at);
@ -400,16 +387,44 @@ const ISSUE_STATUS_CLASS_NAMES: Record<IssueRecord['status'], string> = {
dismissed: styles.issueStatusDismissed, dismissed: styles.issueStatusDismissed,
}; };
const ISSUE_PRIORITY_CLASS_NAMES: Record<'high' | 'low' | 'normal', string> = { const ISSUE_PRIORITY_CLASS_NAMES: Record<IssuePriority, string> = {
high: styles.issuePriorityHigh, high: styles.issuePriorityHigh,
low: styles.issuePriorityLow, low: styles.issuePriorityLow,
normal: styles.issuePriorityNormal, normal: styles.issuePriorityNormal,
}; };
function getIssueStatusFilterLabel(status: IssuesSearch['status']): string {
if (status === 'all') return 'All Statuses';
return getIssueStatusMeta(status)?.label || status.replace(/_/g, ' ');
}
function getIssueStatusClassName(status: IssueRecord['status']): string { function getIssueStatusClassName(status: IssueRecord['status']): string {
return ISSUE_STATUS_CLASS_NAMES[status] || styles.issueStatusOpen; return ISSUE_STATUS_CLASS_NAMES[status] || styles.issueStatusOpen;
} }
function getIssuePriorityClassName(priority: 'high' | 'low' | 'normal'): string { function getIssuePriorityClassName(priority: IssuePriority): string {
return ISSUE_PRIORITY_CLASS_NAMES[priority] || styles.issuePriorityNormal; return ISSUE_PRIORITY_CLASS_NAMES[priority] || styles.issuePriorityNormal;
} }
const ISSUE_CATEGORY_FILTER_GROUPS = [
{
label: 'Track Issues',
matches: (applies: Array<'track' | 'album' | 'artist'>) =>
applies.length === 1 && applies.includes('track'),
},
{
label: 'Album Issues',
matches: (applies: Array<'track' | 'album' | 'artist'>) =>
applies.length === 1 && applies.includes('album'),
},
{
label: 'Both',
matches: (applies: Array<'track' | 'album' | 'artist'>) => applies.length > 1,
},
] as const;
function getIssueCategoryFilterOptions(group: (typeof ISSUE_CATEGORY_FILTER_GROUPS)[number]) {
return ISSUE_CATEGORY_VALUES.filter((category) =>
group.matches(ISSUE_CATEGORY_META[category].applies),
);
}

View file

@ -7,11 +7,11 @@ import {
issueDetailQueryOptions, issueDetailQueryOptions,
issueListQueryOptions, issueListQueryOptions,
} from './-issues.api'; } from './-issues.api';
import { normalizeIssuesSearch } from './-issues.helpers'; import { ISSUE_SEARCH_SCHEMA } from './-issues.types';
import { IssuesPage } from './-ui/issues-page'; import { IssuesPage } from './-ui/issues-page';
export const Route = createFileRoute('/issues')({ export const Route = createFileRoute('/issues')({
validateSearch: normalizeIssuesSearch, validateSearch: ISSUE_SEARCH_SCHEMA,
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
const { bridge } = context.shell; const { bridge } = context.shell;