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
pull/388/head
Antti Kettunen 2 weeks ago
parent fb29e0179e
commit 6471b291fa
No known key found for this signature in database
GPG Key ID: C6B2A3D250359BD7

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

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

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

@ -1,24 +1,27 @@
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', () => {
expect(normalizeIssuesSearch({ category: 'not_real' })).toEqual({
expect(ISSUE_SEARCH_SCHEMA.parse({ category: 'not_real' })).toEqual({
status: 'open',
category: 'all',
issueId: undefined,
});
});
it('preserves known categories', () => {
expect(normalizeIssuesSearch({ category: 'wrong_metadata' })).toEqual({
expect(ISSUE_SEARCH_SCHEMA.parse({ category: 'wrong_metadata' })).toEqual({
status: 'open',
category: 'wrong_metadata',
issueId: undefined,
});
});
it('drops invalid issue ids', () => {
expect(normalizeIssuesSearch({ issueId: 'abc123' })).toEqual({
expect(ISSUE_SEARCH_SCHEMA.parse({ issueId: 'abc123' })).toEqual({
status: 'open',
category: 'all',
issueId: undefined,
@ -26,7 +29,7 @@ describe('normalizeIssuesSearch', () => {
});
it('normalizes numeric issue ids', () => {
expect(normalizeIssuesSearch({ issueId: '7' })).toEqual({
expect(ISSUE_SEARCH_SCHEMA.parse({ issueId: '7' })).toEqual({
status: 'open',
category: 'all',
issueId: 7,

@ -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 DEFAULT_ISSUES_SEARCH = {
status: 'open',
category: 'all',
} satisfies Required<Pick<IssuesSearch, 'status' | 'category'>>;
export const ISSUE_CATEGORY_META: Record<
string,
IssueCategory,
{ label: string; icon: string; description: string; applies: Array<'track' | 'album' | 'artist'> }
> = {
wrong_track: {
@ -73,13 +74,7 @@ export const ISSUE_CATEGORY_META: Record<
},
};
const ISSUE_CATEGORY_KEYS = new Set(Object.keys(ISSUE_CATEGORY_META));
export type NormalizedIssuesSearch = Required<Pick<IssuesSearch, 'status' | 'category'>> & {
issueId?: number;
};
export const ISSUE_STATUS_META: Record<string, { label: string; className: string }> = {
export const ISSUE_STATUS_META: Record<IssueStatus, { label: string; className: string }> = {
open: { label: 'Open', className: 'is-open' },
in_progress: { label: 'In Progress', className: 'is-progress' },
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 {
const label = ISSUE_CATEGORY_META[category]?.label || 'Issue';
const label = getIssueCategoryMeta(category)?.label || 'Issue';
return `${label}: ${entityName || 'Unknown'}`;
}
export function normalizeIssuesSearch(search: IssuesSearch | undefined): NormalizedIssuesSearch {
const status = search?.status;
const category = search?.category;
const issueId = search?.issueId;
return {
status:
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 getIssueCategoryMeta(category: string) {
return ISSUE_CATEGORY_META[category as IssueCategory];
}
export function getIssueStatusMeta(status: string) {
return ISSUE_STATUS_META[status as IssueStatus];
}
export function dispatchIssuesRefreshEvent() {
@ -179,10 +158,10 @@ export function formatIssueDate(value?: 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 === 'low') return 'low';
return 'normal';

@ -1,6 +1,45 @@
export type IssueStatus = 'open' | 'in_progress' | 'resolved' | 'dismissed';
export type IssueEntityType = 'track' | 'album' | 'artist';
export type IssuePriority = 'low' | 'normal' | 'high';
import { z } from 'zod';
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> {
bitrate?: string | number;
@ -101,12 +140,6 @@ export interface IssueCountsResponse {
error?: string;
}
export interface IssuesSearch {
category?: string;
issueId?: string | number;
status?: IssueStatus | 'all';
}
export interface CreateIssuePayload {
entity_type: IssueEntityType;
entity_id: string;

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

@ -1,6 +1,7 @@
import { useForm } from '@tanstack/react-form';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { z } from 'zod';
import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog';
import {
@ -17,7 +18,7 @@ import {
import { Show } from '@/components/primitives';
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 {
@ -26,24 +27,9 @@ import {
getIssueCategoriesForEntity,
getEntityLabel,
} from '../-issues.helpers';
import { ISSUE_CATEGORY_VALUES, ISSUE_PRIORITY_VALUES } from '../-issues.types';
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() {
const queryClient = useQueryClient();
const profile = useProfile();
@ -287,7 +273,7 @@ function ReportIssueModal({
label="Priority"
>
<OptionButtonGroup>
{(['low', 'normal', 'high'] as const).map((priority) => (
{ISSUE_PRIORITY_VALUES.map((priority) => (
<OptionButton
key={priority}
onClick={() => field.handleChange(priority)}
@ -344,24 +330,53 @@ function ReportIssueModal({
);
}
function normalizeReportIssueFormValues(values: ReportIssueFormValues): ReportIssueFormValues {
return {
category: values.category,
description: values.description.trim(),
priority: values.priority,
title: values.title.trim(),
};
const ISSUE_DOMAIN_QUERY_KEY = ['issues'] as const;
const DEFAULT_REPORT_ISSUE_VALUES: ReportIssueFormValues = {
category: '',
description: '',
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(
profileId: number,
values: ReportIssueFormValues,
): string | undefined {
const normalizedValues = normalizeReportIssueFormValues(values);
if (!profileId) return 'Profile is still loading';
if (!normalizedValues.category) return 'Please select an issue category';
if (!normalizedValues.title) return 'Please provide a title for the issue';
return undefined;
const result = reportIssueFormBaseSchema.safeParse(values);
if (result.success) return undefined;
return result.error.issues[0]?.message || 'Unable to submit this issue';
}
function getReportIssueFormError(errors: Array<unknown>): string {
@ -374,14 +389,3 @@ function getReportIssueFormError(errors: Array<unknown>): string {
}
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);
}

@ -6,7 +6,7 @@ import { Select } from '@/components/form';
import { Show } from '@/components/primitives';
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 {
@ -20,9 +20,11 @@ import {
getPriorityClassName,
ISSUE_CATEGORY_META,
ISSUE_STATUS_META,
normalizeIssuesSearch,
getIssueCategoryMeta,
getIssueStatusMeta,
parseSnapshot,
} from '../-issues.helpers';
import { ISSUE_CATEGORY_VALUES, ISSUE_SEARCH_STATUS_VALUES } from '../-issues.types';
import { Route } from '../route';
import { IssueDetailModal } from './issue-detail-modal';
import styles from './issues-page.module.css';
@ -35,7 +37,7 @@ export function IssuesPage() {
const clearIssueSelection = () => {
void navigate({
to: Route.fullPath,
search: (prev) => normalizeIssuesSearch({ ...prev, issueId: undefined }),
search: (prev) => ({ ...prev, issueId: undefined }),
replace: true,
});
};
@ -82,30 +84,22 @@ function IssueBoard() {
const openIssue = (issueId: number) => {
void navigate({
to: Route.fullPath,
search: (prev) => normalizeIssuesSearch({ ...prev, issueId }),
search: (prev) => ({ ...prev, issueId }),
});
};
const onCategoryChange = (category: string) => {
const onCategoryChange = (category: IssuesSearch['category']) => {
void navigate({
to: Route.fullPath,
search: (prev) =>
normalizeIssuesSearch({
...prev,
category,
}),
search: (prev) => ({ ...prev, category }),
replace: true,
});
};
const onStatusChange = (status: IssueStatus | 'all') => {
const onStatusChange = (status: IssuesSearch['status']) => {
void navigate({
to: Route.fullPath,
search: (prev) =>
normalizeIssuesSearch({
...prev,
status,
}),
search: (prev) => ({ ...prev, status }),
replace: true,
});
};
@ -140,11 +134,11 @@ function IssueBoardHeader({
onCategoryChange,
onStatusChange,
}: {
category: string;
category: IssuesSearch['category'];
isAdmin: boolean;
status: IssueStatus | 'all';
onCategoryChange: (category: string) => void;
onStatusChange: (status: IssueStatus | 'all') => void;
status: IssuesSearch['status'];
onCategoryChange: (category: IssuesSearch['category']) => void;
onStatusChange: (status: IssuesSearch['status']) => void;
}) {
return (
<div className={styles.issuesHeader} id="issues-header">
@ -162,37 +156,30 @@ function IssueBoardHeader({
id="issues-filter-status"
aria-label="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>
<option value="all">All Statuses</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="dismissed">Dismissed</option>
{ISSUE_SEARCH_STATUS_VALUES.map((option) => (
<option key={option} value={option}>
{getIssueStatusFilterLabel(option)}
</option>
))}
</Select>
<Select
id="issues-filter-category"
aria-label="Category"
value={category}
onChange={(event) => onCategoryChange(event.target.value)}
onChange={(event) => onCategoryChange(event.target.value as IssuesSearch['category'])}
>
<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>
{ISSUE_CATEGORY_FILTER_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label}>
{getIssueCategoryFilterOptions(group).map((option) => (
<option key={option} value={option}>
{ISSUE_CATEGORY_META[option].label}
</option>
))}
</optgroup>
))}
</Select>
</div>
</div>
@ -256,7 +243,7 @@ function IssueBoardList({
issuesLoading: boolean;
onIssueSelect: (issueId: number) => void;
showReporterName: boolean;
statusFilter: IssueStatus | 'all';
statusFilter: IssuesSearch['status'];
}) {
return (
<div className={styles.issuesList} id="issues-list" data-testid="issue-list">
@ -325,8 +312,8 @@ function IssueBoardCard({
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 statusMeta = getIssueStatusMeta(issue.status) || ISSUE_STATUS_META.open;
const catMeta = getIssueCategoryMeta(issue.category) || ISSUE_CATEGORY_META.other;
const priorityClass = getIssuePriorityClassName(getPriorityClassName(issue.priority));
const statusClassName = getIssueStatusClassName(issue.status);
const createdDate = formatIssueDate(issue.created_at);
@ -400,16 +387,44 @@ const ISSUE_STATUS_CLASS_NAMES: Record<IssueRecord['status'], string> = {
dismissed: styles.issueStatusDismissed,
};
const ISSUE_PRIORITY_CLASS_NAMES: Record<'high' | 'low' | 'normal', string> = {
const ISSUE_PRIORITY_CLASS_NAMES: Record<IssuePriority, string> = {
high: styles.issuePriorityHigh,
low: styles.issuePriorityLow,
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 {
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;
}
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),
);
}

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

Loading…
Cancel
Save