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
This commit is contained in:
Antti Kettunen 2026-04-07 09:03:19 +03:00
parent 577e4bdace
commit d65ecbe464
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
4 changed files with 283 additions and 100 deletions

View file

@ -6,6 +6,7 @@
"": { "": {
"name": "soulsync-webui", "name": "soulsync-webui",
"dependencies": { "dependencies": {
"@tanstack/react-form": "^1.28.6",
"@tanstack/react-query": "^5.96.2", "@tanstack/react-query": "^5.96.2",
"@tanstack/react-router": "^1.168.10", "@tanstack/react-router": "^1.168.10",
"ky": "^2.0.0-0", "ky": "^2.0.0-0",
@ -2187,6 +2188,37 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@tanstack/history": {
"version": "1.161.6", "version": "1.161.6",
"resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz",
@ -2200,6 +2232,19 @@
"url": "https://github.com/sponsors/tannerlinsley" "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": { "node_modules/@tanstack/query-core": {
"version": "5.96.2", "version": "5.96.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz",
@ -2210,6 +2255,28 @@
"url": "https://github.com/sponsors/tannerlinsley" "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": { "node_modules/@tanstack/react-query": {
"version": "5.96.2", "version": "5.96.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz",

View file

@ -10,6 +10,7 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-form": "^1.28.6",
"@tanstack/react-query": "^5.96.2", "@tanstack/react-query": "^5.96.2",
"@tanstack/react-router": "^1.168.10", "@tanstack/react-router": "^1.168.10",
"ky": "^2.0.0-0", "ky": "^2.0.0-0",

View file

@ -179,7 +179,11 @@ describe('issues route', () => {
}); });
fireEvent.click(await screen.findByRole('button', { name: /wrong cover art/i })); 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 })); fireEvent.click(screen.getByRole('button', { name: /submit issue/i }));
await waitFor(() => { await waitFor(() => {

View file

@ -1,3 +1,4 @@
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 { createPortal } from 'react-dom'; 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; 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 bridge = useShellBridge(); const bridge = useShellBridge();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -71,6 +86,7 @@ export function IssueDomainHost() {
return createPortal( return createPortal(
<ReportIssueModal <ReportIssueModal
key={`${reportPayload.entityType}:${reportPayload.entityId}`}
payload={reportPayload} payload={reportPayload}
profileId={profileId} profileId={profileId}
onClose={() => setReportPayload(null)} onClose={() => setReportPayload(null)}
@ -94,13 +110,6 @@ function ReportIssueModal({
payload: IssueReportPayload; payload: IssueReportPayload;
profileId: number; profileId: number;
}) { }) {
const [selectedCategory, setSelectedCategory] = useState('');
const [selectedPriority, setSelectedPriority] = useState<IssuePriority>('normal');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [titleEdited, setTitleEdited] = useState(false);
const [error, setError] = useState('');
const categories = useMemo( const categories = useMemo(
() => getIssueCategoriesForEntity(payload.entityType), () => getIssueCategoriesForEntity(payload.entityType),
[payload.entityType], [payload.entityType],
@ -109,40 +118,44 @@ function ReportIssueModal({
payload.entityType === 'track' ? 'Track' : payload.entityType === 'album' ? 'Album' : 'Artist'; payload.entityType === 'track' ? 'Track' : payload.entityType === 'album' ? 'Album' : 'Artist';
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async () => { mutationFn: async (values: ReportIssueFormValues) => {
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');
await createIssue(profileId, { await createIssue(profileId, {
entity_type: payload.entityType, entity_type: payload.entityType,
entity_id: String(payload.entityId), entity_id: String(payload.entityId),
category: selectedCategory, category: values.category,
title: trimmedTitle, title: values.title,
description: description.trim(), description: values.description,
priority: selectedPriority, priority: values.priority,
}); });
}, },
onError: (mutationError) => {
const message =
mutationError instanceof Error ? mutationError.message : 'Failed to submit issue';
setError(message);
notify(message, 'error');
},
onSuccess: () => { onSuccess: () => {
notify('Issue reported successfully', 'success'); notify('Issue reported successfully', 'success');
onSubmitted(); onSubmitted();
}, },
}); });
function selectCategory(category: string) { const form = useForm({
setSelectedCategory(category); defaultValues: DEFAULT_REPORT_ISSUE_VALUES,
setError(''); validators: {
if (!titleEdited) { onSubmit: ({ value }) => validateReportIssueForm(profileId, value),
setTitle(createDefaultIssueTitle(category, payload.entityName)); },
} 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 ( return (
<div <div
@ -152,9 +165,14 @@ function ReportIssueModal({
aria-labelledby="report-issue-title" aria-labelledby="report-issue-title"
onClick={onClose} onClick={onClose}
> >
<div <form
className={`${styles.modal} ${styles.reportIssueModal}`} className={`${styles.modal} ${styles.reportIssueModal}`}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit().catch(() => undefined);
}}
> >
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h3 className={styles.modalHeaderTitle} id="report-issue-title"> <h3 className={styles.modalHeaderTitle} id="report-issue-title">
@ -184,70 +202,123 @@ function ReportIssueModal({
<div className={styles.issueDetailSection}> <div className={styles.issueDetailSection}>
<label className={styles.issueDetailSectionTitle}>What's the problem?</label> <label className={styles.issueDetailSectionTitle}>What's the problem?</label>
<div className={styles.reportIssueCategoryGrid}> <div className={styles.reportIssueCategoryGrid}>
{categories.map(([category, meta]) => ( <form.Field name="category">
<button {(field) =>
key={category} categories.map(([category, meta]) => (
className={`${styles.reportIssueCategoryCard} ${ <button
selectedCategory === category ? styles.reportIssueCategoryCardSelected : '' key={category}
}`} className={`${styles.reportIssueCategoryCard} ${
type="button" field.state.value === category
onClick={() => selectCategory(category)} ? styles.reportIssueCategoryCardSelected
> : ''
<div className={styles.reportIssueCategoryIcon}>{meta.icon}</div> }`}
<div className={styles.reportIssueCategoryLabel}>{meta.label}</div> type="button"
<div className={styles.reportIssueCategoryDesc}>{meta.description}</div> onClick={() => {
</button> field.handleChange(category);
))} createMutation.reset();
form.setErrorMap({ onSubmit: undefined });
if (!form.getFieldMeta('title')?.isTouched) {
form.setFieldValue(
'title',
createDefaultIssueTitle(category, payload.entityName),
{ dontUpdateMeta: true },
);
}
}}
>
<div className={styles.reportIssueCategoryIcon}>{meta.icon}</div>
<div className={styles.reportIssueCategoryLabel}>{meta.label}</div>
<div className={styles.reportIssueCategoryDesc}>{meta.description}</div>
</button>
))
}
</form.Field>
</div> </div>
</div> </div>
{selectedCategory ? ( <form.Subscribe selector={(state) => state.values.category}>
<div className={styles.issueDetailSection}> {(selectedCategory) =>
<label className={styles.issueDetailSectionTitle} htmlFor="report-issue-title-input"> selectedCategory ? (
Title <div className={styles.issueDetailSection}>
</label> <form.Field name="title">
<input {(field) => (
className={styles.reportIssueInput} <>
id="report-issue-title-input" <label
maxLength={200} className={styles.issueDetailSectionTitle}
onChange={(event) => { htmlFor="report-issue-title-input"
setTitle(event.target.value); >
setTitleEdited(true); Title
}} </label>
placeholder="Brief summary of the issue..." <input
value={title} className={styles.reportIssueInput}
/> id="report-issue-title-input"
<label className={styles.issueDetailSectionTitle} htmlFor="report-issue-desc-input"> maxLength={200}
Details onBlur={field.handleBlur}
</label> onChange={(event) => {
<textarea field.handleChange(event.target.value);
className={styles.issueDetailResponseTextarea} createMutation.reset();
id="report-issue-desc-input" form.setErrorMap({ onSubmit: undefined });
maxLength={2000} }}
onChange={(event) => setDescription(event.target.value)} placeholder="Brief summary of the issue..."
placeholder="Provide more details about what's wrong..." value={field.state.value}
rows={4} />
value={description} </>
/> )}
<div className={styles.reportIssuePriorityRow} aria-label="Priority"> </form.Field>
{(['low', 'normal', 'high'] as const).map((priority) => ( <form.Field name="description">
<button {(field) => (
key={priority} <>
className={`${styles.reportIssuePriorityButton} ${ <label
selectedPriority === priority ? styles.reportIssuePriorityButtonSelected : '' className={styles.issueDetailSectionTitle}
}`} htmlFor="report-issue-desc-input"
type="button" >
onClick={() => setSelectedPriority(priority)} Details
> </label>
{priority[0].toUpperCase()} <textarea
{priority.slice(1)} className={styles.issueDetailResponseTextarea}
</button> id="report-issue-desc-input"
))} maxLength={2000}
</div> onBlur={field.handleBlur}
</div> onChange={(event) => field.handleChange(event.target.value)}
) : null} placeholder="Provide more details about what's wrong..."
rows={4}
value={field.state.value}
/>
</>
)}
</form.Field>
<form.Field name="priority">
{(field) => (
<div className={styles.reportIssuePriorityRow} aria-label="Priority">
{(['low', 'normal', 'high'] as const).map((priority) => (
<button
key={priority}
className={`${styles.reportIssuePriorityButton} ${
field.state.value === priority
? styles.reportIssuePriorityButtonSelected
: ''
}`}
type="button"
onClick={() => field.handleChange(priority)}
>
{priority[0].toUpperCase()}
{priority.slice(1)}
</button>
))}
</div>
)}
</form.Field>
</div>
) : null
}
</form.Subscribe>
{error ? <div className={styles.reportIssueError}>{error}</div> : null} <form.Subscribe selector={(state) => state.errors}>
{(errors) => {
const error = getReportIssueFormError(errors);
return error ? <div className={styles.reportIssueError}>{error}</div> : null;
}}
</form.Subscribe>
</div> </div>
<div className={styles.modalFooter}> <div className={styles.modalFooter}>
@ -258,20 +329,60 @@ function ReportIssueModal({
> >
Cancel Cancel
</button> </button>
<button <form.Subscribe
className={`${styles.modalButton} ${styles.modalButtonPrimary}`} selector={(state) => ({
type="button" category: state.values.category,
disabled={!selectedCategory || createMutation.isPending} isSubmitting: state.isSubmitting,
onClick={() => createMutation.mutate()} title: state.values.title,
})}
> >
{createMutation.isPending ? 'Submitting...' : 'Submit Issue'} {(state) => {
</button> const isSubmitting = state.isSubmitting || createMutation.isPending;
return (
<button
className={`${styles.modalButton} ${styles.modalButtonPrimary}`}
type="submit"
disabled={!state.category || !state.title.trim() || isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Issue'}
</button>
);
}}
</form.Subscribe>
</div> </div>
</div> </form>
</div> </div>
); );
} }
function normalizeReportIssueFormValues(values: ReportIssueFormValues): ReportIssueFormValues {
return {
category: values.category,
description: values.description.trim(),
priority: values.priority,
title: values.title.trim(),
};
}
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;
}
function getReportIssueFormError(errors: Array<unknown>): string {
const error = errors.find(Boolean);
if (!error) return '';
if (typeof error === 'string') return error;
if (error instanceof Error) return error.message;
return String(error);
}
function notify(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') { function notify(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') {
window.showToast?.(message, type); window.showToast?.(message, type);
} }