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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue