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:
parent
577e4bdace
commit
d65ecbe464
4 changed files with 283 additions and 100 deletions
67
webui/package-lock.json
generated
67
webui/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue