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",
|
"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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue