diff --git a/webui/package-lock.json b/webui/package-lock.json index fbe53a82..da9dea53 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -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" diff --git a/webui/package.json b/webui/package.json index 7d0f2eaf..2e076f38 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/src/routes/issues/-issues.api.ts b/webui/src/routes/issues/-issues.api.ts index 77dc581f..5dba4467 100644 --- a/webui/src/routes/issues/-issues.api.ts +++ b/webui/src/routes/issues/-issues.api.ts @@ -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 export async function fetchIssueList( profileId: number, - search: Pick, + search: Pick, ): Promise { 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, + search: Pick, ) { return queryOptions({ queryKey: ['issues', 'list', profileId, search.status, search.category], diff --git a/webui/src/routes/issues/-issues.helpers.test.ts b/webui/src/routes/issues/-issues.helpers.test.ts index 759021a4..73aec7e9 100644 --- a/webui/src/routes/issues/-issues.helpers.test.ts +++ b/webui/src/routes/issues/-issues.helpers.test.ts @@ -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, diff --git a/webui/src/routes/issues/-issues.helpers.ts b/webui/src/routes/issues/-issues.helpers.ts index 551809ce..6c07b654 100644 --- a/webui/src/routes/issues/-issues.helpers.ts +++ b/webui/src/routes/issues/-issues.helpers.ts @@ -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>; - 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> & { - issueId?: number; -}; - -export const ISSUE_STATUS_META: Record = { +export const ISSUE_STATUS_META: Record = { 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'; diff --git a/webui/src/routes/issues/-issues.types.ts b/webui/src/routes/issues/-issues.types.ts index 8f587bfa..68639ee4 100644 --- a/webui/src/routes/issues/-issues.types.ts +++ b/webui/src/routes/issues/-issues.types.ts @@ -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; export interface IssueTrackRow extends Record { 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; diff --git a/webui/src/routes/issues/-ui/issue-detail-modal.tsx b/webui/src/routes/issues/-ui/issue-detail-modal.tsx index 6002d43a..89c8de87 100644 --- a/webui/src/routes/issues/-ui/issue-detail-modal.tsx +++ b/webui/src/routes/issues/-ui/issue-detail-modal.tsx @@ -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({ ) : (
- {ISSUE_CATEGORY_META[issue.category]?.icon || ISSUE_CATEGORY_META.other.icon} + {issueCategoryMeta?.icon || ISSUE_CATEGORY_META.other.icon}
)} @@ -298,7 +298,7 @@ export function IssueDetailModal({
{formatStatusLabel(issue.status)} diff --git a/webui/src/routes/issues/-ui/issue-domain-host.tsx b/webui/src/routes/issues/-ui/issue-domain-host.tsx index 6c150b8f..8a24c109 100644 --- a/webui/src/routes/issues/-ui/issue-domain-host.tsx +++ b/webui/src/routes/issues/-ui/issue-domain-host.tsx @@ -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" > - {(['low', 'normal', 'high'] as const).map((priority) => ( + {ISSUE_PRIORITY_VALUES.map((priority) => ( 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; +type NormalizedReportIssueFormValues = z.output; + +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): string { @@ -374,14 +389,3 @@ function getReportIssueFormError(errors: Array): 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); -} diff --git a/webui/src/routes/issues/-ui/issues-page.tsx b/webui/src/routes/issues/-ui/issues-page.tsx index 8395c366..e15fbb18 100644 --- a/webui/src/routes/issues/-ui/issues-page.tsx +++ b/webui/src/routes/issues/-ui/issues-page.tsx @@ -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 (
@@ -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'])} > - - - - - + {ISSUE_SEARCH_STATUS_VALUES.map((option) => ( + + ))}
@@ -256,7 +243,7 @@ function IssueBoardList({ issuesLoading: boolean; onIssueSelect: (issueId: number) => void; showReporterName: boolean; - statusFilter: IssueStatus | 'all'; + statusFilter: IssuesSearch['status']; }) { return (
@@ -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 = { dismissed: styles.issueStatusDismissed, }; -const ISSUE_PRIORITY_CLASS_NAMES: Record<'high' | 'low' | 'normal', string> = { +const ISSUE_PRIORITY_CLASS_NAMES: Record = { 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), + ); +} diff --git a/webui/src/routes/issues/route.tsx b/webui/src/routes/issues/route.tsx index 1de18b39..3824cbac 100644 --- a/webui/src/routes/issues/route.tsx +++ b/webui/src/routes/issues/route.tsx @@ -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;