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:
parent
fb29e0179e
commit
6471b291fa
10 changed files with 213 additions and 158 deletions
30
webui/package-lock.json
generated
30
webui/package-lock.json
generated
|
|
@ -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;
|
||||
export function getIssueCategoryMeta(category: string) {
|
||||
return ISSUE_CATEGORY_META[category as IssueCategory];
|
||||
}
|
||||
|
||||
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 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…
Reference in a new issue