From d65ecbe46499927e2a5b056678d1abbf1ae1a27f Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Tue, 7 Apr 2026 09:03:19 +0300 Subject: [PATCH] Adopt TanStack Form for issue reporting - Add @tanstack/react-form to the web UI dependencies - Move the report issue composer fields and submit validation onto TanStack Form - Route submit and server errors through form error state while keeping React Query for mutation execution - Extend issue route coverage for preserving custom report titles across category changes --- webui/package-lock.json | 67 ++++ webui/package.json | 1 + webui/src/routes/issues/-route.test.tsx | 6 +- .../routes/issues/-ui/issue-domain-host.tsx | 309 ++++++++++++------ 4 files changed, 283 insertions(+), 100 deletions(-) diff --git a/webui/package-lock.json b/webui/package-lock.json index 85a7ef71..1765d848 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "soulsync-webui", "dependencies": { + "@tanstack/react-form": "^1.28.6", "@tanstack/react-query": "^5.96.2", "@tanstack/react-router": "^1.168.10", "ky": "^2.0.0-0", @@ -2187,6 +2188,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz", + "integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==", + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.28.6", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.28.6.tgz", + "integrity": "sha512-4zroxL6VDj5O+w7l3dYZnUeL/h30KtNSV7UWzKAL7cl+8clMFdISPDlDlluS37As7oqvPVKo8B83VlIBvgmRog==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/history": { "version": "1.161.6", "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", @@ -2200,6 +2232,19 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.96.2", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", @@ -2210,6 +2255,28 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/react-form": { + "version": "1.28.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.28.6.tgz", + "integrity": "sha512-dRxwKeNW3uuJvf0sXsIQ2compFMnIJNk9B436Lx0fqkqK+CBvA1tNmEdX+faoCpuQ5Wua3c8ahVibJ65cpkijA==", + "license": "MIT", + "dependencies": { + "@tanstack/form-core": "1.28.6", + "@tanstack/react-store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, "node_modules/@tanstack/react-query": { "version": "5.96.2", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz", diff --git a/webui/package.json b/webui/package.json index 430b5688..40347a9e 100644 --- a/webui/package.json +++ b/webui/package.json @@ -10,6 +10,7 @@ "test:e2e": "playwright test" }, "dependencies": { + "@tanstack/react-form": "^1.28.6", "@tanstack/react-query": "^5.96.2", "@tanstack/react-router": "^1.168.10", "ky": "^2.0.0-0", diff --git a/webui/src/routes/issues/-route.test.tsx b/webui/src/routes/issues/-route.test.tsx index 6ea71f4d..b52745da 100644 --- a/webui/src/routes/issues/-route.test.tsx +++ b/webui/src/routes/issues/-route.test.tsx @@ -179,7 +179,11 @@ describe('issues route', () => { }); fireEvent.click(await screen.findByRole('button', { name: /wrong cover art/i })); - expect(screen.getByLabelText(/title/i)).toHaveValue('Wrong Cover Art: Album Name'); + const titleInput = screen.getByLabelText(/title/i); + expect(titleInput).toHaveValue('Wrong Cover Art: Album Name'); + fireEvent.change(titleInput, { target: { value: 'Custom report title' } }); + fireEvent.click(screen.getByRole('button', { name: /missing tracks/i })); + expect(titleInput).toHaveValue('Custom report title'); fireEvent.click(screen.getByRole('button', { name: /submit issue/i })); await waitFor(() => { diff --git a/webui/src/routes/issues/-ui/issue-domain-host.tsx b/webui/src/routes/issues/-ui/issue-domain-host.tsx index 8252bc60..4a2e8fe2 100644 --- a/webui/src/routes/issues/-ui/issue-domain-host.tsx +++ b/webui/src/routes/issues/-ui/issue-domain-host.tsx @@ -1,3 +1,4 @@ +import { useForm } from '@tanstack/react-form'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -18,6 +19,20 @@ 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 bridge = useShellBridge(); const queryClient = useQueryClient(); @@ -71,6 +86,7 @@ export function IssueDomainHost() { return createPortal( setReportPayload(null)} @@ -94,13 +110,6 @@ function ReportIssueModal({ payload: IssueReportPayload; profileId: number; }) { - const [selectedCategory, setSelectedCategory] = useState(''); - const [selectedPriority, setSelectedPriority] = useState('normal'); - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [titleEdited, setTitleEdited] = useState(false); - const [error, setError] = useState(''); - const categories = useMemo( () => getIssueCategoriesForEntity(payload.entityType), [payload.entityType], @@ -109,40 +118,44 @@ function ReportIssueModal({ payload.entityType === 'track' ? 'Track' : payload.entityType === 'album' ? 'Album' : 'Artist'; const createMutation = useMutation({ - mutationFn: async () => { - if (!profileId) throw new Error('Profile is still loading'); - if (!selectedCategory) throw new Error('Please select an issue category'); - const trimmedTitle = title.trim(); - if (!trimmedTitle) throw new Error('Please provide a title for the issue'); - + mutationFn: async (values: ReportIssueFormValues) => { await createIssue(profileId, { entity_type: payload.entityType, entity_id: String(payload.entityId), - category: selectedCategory, - title: trimmedTitle, - description: description.trim(), - priority: selectedPriority, + category: values.category, + title: values.title, + description: values.description, + priority: values.priority, }); }, - onError: (mutationError) => { - const message = - mutationError instanceof Error ? mutationError.message : 'Failed to submit issue'; - setError(message); - notify(message, 'error'); - }, onSuccess: () => { notify('Issue reported successfully', 'success'); onSubmitted(); }, }); - function selectCategory(category: string) { - setSelectedCategory(category); - setError(''); - if (!titleEdited) { - setTitle(createDefaultIssueTitle(category, payload.entityName)); - } - } + const form = useForm({ + defaultValues: DEFAULT_REPORT_ISSUE_VALUES, + validators: { + onSubmit: ({ value }) => validateReportIssueForm(profileId, value), + }, + onSubmit: async ({ value, formApi }) => { + const normalizedValues = normalizeReportIssueFormValues(value); + + createMutation.reset(); + formApi.setErrorMap({ onSubmit: undefined }); + + try { + await createMutation.mutateAsync(normalizedValues); + } catch (mutationError) { + const message = + mutationError instanceof Error ? mutationError.message : 'Failed to submit issue'; + formApi.setErrorMap({ onSubmit: message }); + notify(message, 'error'); + throw mutationError; + } + }, + }); return (
-
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + event.stopPropagation(); + void form.handleSubmit().catch(() => undefined); + }} >

@@ -184,70 +202,123 @@ function ReportIssueModal({
- {categories.map(([category, meta]) => ( - - ))} + + {(field) => + categories.map(([category, meta]) => ( + + )) + } +
- {selectedCategory ? ( -
- - { - setTitle(event.target.value); - setTitleEdited(true); - }} - placeholder="Brief summary of the issue..." - value={title} - /> - -