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",
"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",

View file

@ -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",

View file

@ -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(() => {

View file

@ -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(
<ReportIssueModal
key={`${reportPayload.entityType}:${reportPayload.entityId}`}
payload={reportPayload}
profileId={profileId}
onClose={() => setReportPayload(null)}
@ -94,13 +110,6 @@ function ReportIssueModal({
payload: IssueReportPayload;
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(
() => 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 (
<div
@ -152,9 +165,14 @@ function ReportIssueModal({
aria-labelledby="report-issue-title"
onClick={onClose}
>
<div
<form
className={`${styles.modal} ${styles.reportIssueModal}`}
onClick={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit().catch(() => undefined);
}}
>
<div className={styles.modalHeader}>
<h3 className={styles.modalHeaderTitle} id="report-issue-title">
@ -184,70 +202,123 @@ function ReportIssueModal({
<div className={styles.issueDetailSection}>
<label className={styles.issueDetailSectionTitle}>What's the problem?</label>
<div className={styles.reportIssueCategoryGrid}>
{categories.map(([category, meta]) => (
<button
key={category}
className={`${styles.reportIssueCategoryCard} ${
selectedCategory === category ? styles.reportIssueCategoryCardSelected : ''
}`}
type="button"
onClick={() => selectCategory(category)}
>
<div className={styles.reportIssueCategoryIcon}>{meta.icon}</div>
<div className={styles.reportIssueCategoryLabel}>{meta.label}</div>
<div className={styles.reportIssueCategoryDesc}>{meta.description}</div>
</button>
))}
<form.Field name="category">
{(field) =>
categories.map(([category, meta]) => (
<button
key={category}
className={`${styles.reportIssueCategoryCard} ${
field.state.value === category
? styles.reportIssueCategoryCardSelected
: ''
}`}
type="button"
onClick={() => {
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>
{selectedCategory ? (
<div className={styles.issueDetailSection}>
<label className={styles.issueDetailSectionTitle} htmlFor="report-issue-title-input">
Title
</label>
<input
className={styles.reportIssueInput}
id="report-issue-title-input"
maxLength={200}
onChange={(event) => {
setTitle(event.target.value);
setTitleEdited(true);
}}
placeholder="Brief summary of the issue..."
value={title}
/>
<label className={styles.issueDetailSectionTitle} htmlFor="report-issue-desc-input">
Details
</label>
<textarea
className={styles.issueDetailResponseTextarea}
id="report-issue-desc-input"
maxLength={2000}
onChange={(event) => setDescription(event.target.value)}
placeholder="Provide more details about what's wrong..."
rows={4}
value={description}
/>
<div className={styles.reportIssuePriorityRow} aria-label="Priority">
{(['low', 'normal', 'high'] as const).map((priority) => (
<button
key={priority}
className={`${styles.reportIssuePriorityButton} ${
selectedPriority === priority ? styles.reportIssuePriorityButtonSelected : ''
}`}
type="button"
onClick={() => setSelectedPriority(priority)}
>
{priority[0].toUpperCase()}
{priority.slice(1)}
</button>
))}
</div>
</div>
) : null}
<form.Subscribe selector={(state) => state.values.category}>
{(selectedCategory) =>
selectedCategory ? (
<div className={styles.issueDetailSection}>
<form.Field name="title">
{(field) => (
<>
<label
className={styles.issueDetailSectionTitle}
htmlFor="report-issue-title-input"
>
Title
</label>
<input
className={styles.reportIssueInput}
id="report-issue-title-input"
maxLength={200}
onBlur={field.handleBlur}
onChange={(event) => {
field.handleChange(event.target.value);
createMutation.reset();
form.setErrorMap({ onSubmit: undefined });
}}
placeholder="Brief summary of the issue..."
value={field.state.value}
/>
</>
)}
</form.Field>
<form.Field name="description">
{(field) => (
<>
<label
className={styles.issueDetailSectionTitle}
htmlFor="report-issue-desc-input"
>
Details
</label>
<textarea
className={styles.issueDetailResponseTextarea}
id="report-issue-desc-input"
maxLength={2000}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
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 className={styles.modalFooter}>
@ -258,20 +329,60 @@ function ReportIssueModal({
>
Cancel
</button>
<button
className={`${styles.modalButton} ${styles.modalButtonPrimary}`}
type="button"
disabled={!selectedCategory || createMutation.isPending}
onClick={() => createMutation.mutate()}
<form.Subscribe
selector={(state) => ({
category: state.values.category,
isSubmitting: state.isSubmitting,
title: state.values.title,
})}
>
{createMutation.isPending ? 'Submitting...' : 'Submit Issue'}
</button>
{(state) => {
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>
</form>
</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') {
window.showToast?.(message, type);
}