Format & lint React code

This commit is contained in:
Antti Kettunen 2026-04-27 09:17:25 +03:00
parent 018a554f35
commit a2495aaba7
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
10 changed files with 94 additions and 83 deletions

View file

@ -82,5 +82,12 @@ The recommended dev flow keeps the backend and frontend separate:
``` ```
Vite hot reloads the React side when you change webui files. Vite hot reloads the React side when you change webui files.
For linting and formatting, use:
```bash
npm run check
npm run fix
```
If you want a convenience wrapper, the repo root also includes `./dev.sh`. If you want a convenience wrapper, the repo root also includes `./dev.sh`.
It starts both halves together and is most useful on Linux, macOS, and WSL. It starts both halves together and is most useful on Linux, macOS, and WSL.

View file

@ -3,7 +3,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"check": "vp check src",
"dev": "vp dev", "dev": "vp dev",
"fix": "vp check --fix src",
"build": "vp build", "build": "vp build",
"test": "vp test run", "test": "vp test run",
"test:watch": "vp test", "test:watch": "vp test",

View file

@ -66,7 +66,11 @@ function FormDemo() {
<FormField label="Priority" helperText="Set urgency"> <FormField label="Priority" helperText="Set urgency">
<OptionButtonGroup> <OptionButtonGroup>
{(['low', 'normal', 'high'] as const).map((value) => ( {(['low', 'normal', 'high'] as const).map((value) => (
<OptionButton key={value} onClick={() => setPriority(value)} selected={priority === value}> <OptionButton
key={value}
onClick={() => setPriority(value)}
selected={priority === value}
>
{value[0].toUpperCase()} {value[0].toUpperCase()}
{value.slice(1)} {value.slice(1)}
</OptionButton> </OptionButton>

View file

@ -85,8 +85,7 @@ export function OptionCardGroup({
return <div className={mergeClassNames(styles.optionCardGroup, className)}>{children}</div>; return <div className={mergeClassNames(styles.optionCardGroup, className)}>{children}</div>;
} }
export interface OptionCardProps export interface OptionCardProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
description?: ReactNode; description?: ReactNode;
icon?: ReactNode; icon?: ReactNode;
selected?: boolean; selected?: boolean;
@ -163,7 +162,14 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{ className, type = 'button', ...props }, { className, type = 'button', ...props },
ref, ref,
) { ) {
return <button ref={ref} className={mergeClassNames(styles.button, className)} type={type} {...props} />; return (
<button
ref={ref}
className={mergeClassNames(styles.button, className)}
type={type}
{...props}
/>
);
}); });
export function FormError({ className, message }: { className?: string; message?: ReactNode }) { export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
@ -176,12 +182,6 @@ export function FormError({ className, message }: { className?: string; message?
); );
} }
export function FormActions({ export function FormActions({ className, children }: { children: ReactNode; className?: string }) {
className,
children,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={mergeClassNames(styles.formActions, className)}>{children}</div>; return <div className={mergeClassNames(styles.formActions, className)}>{children}</div>;
} }

View file

@ -1,18 +1,17 @@
import type { ShellProfileContext, ShellRouteDefinition, ShellPageId } from './bridge';
import type { import type {
DownloadMissingAlbumWorkflowInput, DownloadMissingAlbumWorkflowInput,
WishlistAlbumWorkflowInput, WishlistAlbumWorkflowInput,
} from '@/platform/workflows/album-workflows'; } from '@/platform/workflows/album-workflows';
import type { IssueDomainBridge } from '@/routes/issues/-issues.types'; import type { IssueDomainBridge } from '@/routes/issues/-issues.types';
import type { ShellProfileContext, ShellRouteDefinition, ShellPageId } from './bridge';
declare global { declare global {
interface Window { interface Window {
showToast?: (message: string, type?: string, durationOrContext?: number | string) => void; showToast?: (message: string, type?: string, durationOrContext?: number | string) => void;
SoulSyncIssueDomain?: IssueDomainBridge; SoulSyncIssueDomain?: IssueDomainBridge;
SoulSyncWorkflowActions?: { SoulSyncWorkflowActions?: {
openDownloadMissingAlbum: ( openDownloadMissingAlbum: (input: DownloadMissingAlbumWorkflowInput) => void | Promise<void>;
input: DownloadMissingAlbumWorkflowInput,
) => void | Promise<void>;
openAddToWishlistAlbum: (input: WishlistAlbumWorkflowInput) => void | Promise<void>; openAddToWishlistAlbum: (input: WishlistAlbumWorkflowInput) => void | Promise<void>;
notify?: (message: string, type?: string) => void; notify?: (message: string, type?: string) => void;
}; };

View file

@ -204,7 +204,9 @@ describe('issues route', () => {
await waitFor(() => { await waitFor(() => {
expect( expect(
fetchMock.mock.calls.some(([request]) => request instanceof Request && request.method === 'POST'), fetchMock.mock.calls.some(
([request]) => request instanceof Request && request.method === 'POST',
),
).toBe(true); ).toBe(true);
}); });
}); });

View file

@ -1,11 +1,11 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Button, FormField, TextArea } from '@/components/form';
import { import {
launchAlbumDownloadWorkflow, launchAlbumDownloadWorkflow,
launchAlbumWishlistWorkflow, launchAlbumWishlistWorkflow,
} from '@/platform/workflows/album-workflows'; } from '@/platform/workflows/album-workflows';
import { Button, FormField, TextArea } from '@/components/form';
import type { IssueRecord } from '../-issues.types'; import type { IssueRecord } from '../-issues.types';
@ -429,7 +429,9 @@ export function IssueDetailModal({
</span> </span>
<span className={styles.issueDetailTracklistDur}>{duration}</span> <span className={styles.issueDetailTracklistDur}>{duration}</span>
<span className={styles.issueDetailTracklistMeta}> <span className={styles.issueDetailTracklistMeta}>
{format ? <span className={styles.issueTrackBadge}>{format}</span> : null} {format ? (
<span className={styles.issueTrackBadge}>{format}</span>
) : null}
{bitrate ? ( {bitrate ? (
<span className={styles.issueTrackBadge}>{bitrate}</span> <span className={styles.issueTrackBadge}>{bitrate}</span>
) : null} ) : null}
@ -442,27 +444,27 @@ export function IssueDetailModal({
) : null} ) : null}
{issue.entity_type !== 'artist' && isAdmin && ( {issue.entity_type !== 'artist' && isAdmin && (
<div className={styles.issueDetailSection}> <div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Actions</div> <div className={styles.issueDetailSectionTitle}>Admin Actions</div>
<div className={styles.issueActionButtons}> <div className={styles.issueActionButtons}>
<Button <Button
className={styles.issueActionDownload} className={styles.issueActionDownload}
type="button" type="button"
disabled={downloadWorkflowMutation.isPending} disabled={downloadWorkflowMutation.isPending}
onClick={() => downloadWorkflowMutation.mutate(albumWorkflowInput)} onClick={() => downloadWorkflowMutation.mutate(albumWorkflowInput)}
> >
{downloadWorkflowMutation.isPending ? 'Loading...' : 'Download Album'} {downloadWorkflowMutation.isPending ? 'Loading...' : 'Download Album'}
</Button> </Button>
<Button <Button
className={styles.issueActionWishlist} className={styles.issueActionWishlist}
type="button" type="button"
disabled={wishlistWorkflowMutation.isPending} disabled={wishlistWorkflowMutation.isPending}
onClick={() => wishlistWorkflowMutation.mutate(albumWorkflowInput)} onClick={() => wishlistWorkflowMutation.mutate(albumWorkflowInput)}
> >
{wishlistWorkflowMutation.isPending ? 'Loading...' : 'Add to Wishlist'} {wishlistWorkflowMutation.isPending ? 'Loading...' : 'Add to Wishlist'}
</Button> </Button>
</div>
</div> </div>
</div>
)} )}
{isAdmin && ( {isAdmin && (
@ -494,11 +496,7 @@ export function IssueDetailModal({
</div> </div>
<div className={styles.modalFooter}> <div className={styles.modalFooter}>
<Button <Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
className={styles.modalButtonSecondary}
type="button"
onClick={onClose}
>
Close Close
</Button> </Button>
{!isLoading && !error && issue && ( {!isLoading && !error && issue && (
@ -626,8 +624,10 @@ function getTrackMetaItems(snapshot: ReturnType<typeof parseSnapshot>) {
const duration = formatDuration(snapshot.duration); const duration = formatDuration(snapshot.duration);
if (duration) items.push({ icon: 'T', label: 'Duration', value: duration }); if (duration) items.push({ icon: 'T', label: 'Duration', value: duration });
if (snapshot.format) items.push({ icon: 'F', label: 'Format', value: String(snapshot.format) }); if (snapshot.format) items.push({ icon: 'F', label: 'Format', value: String(snapshot.format) });
if (snapshot.bitrate) items.push({ icon: 'B', label: 'Bitrate', value: `${snapshot.bitrate} kbps` }); if (snapshot.bitrate)
items.push({ icon: 'B', label: 'Bitrate', value: `${snapshot.bitrate} kbps` });
if (snapshot.bpm) items.push({ icon: 'M', label: 'BPM', value: String(snapshot.bpm) }); if (snapshot.bpm) items.push({ icon: 'M', label: 'BPM', value: String(snapshot.bpm) });
if (snapshot.quality) items.push({ icon: 'Q', label: 'Quality', value: String(snapshot.quality) }); if (snapshot.quality)
items.push({ icon: 'Q', label: 'Quality', value: String(snapshot.quality) });
return items; return items;
} }

View file

@ -1,9 +1,8 @@
import { useForm } from "@tanstack/react-form"; 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';
import { getShellProfileContext } from "@/platform/shell/bridge";
import { import {
Button, Button,
FormActions, FormActions,
@ -15,10 +14,11 @@ import {
OptionCardGroup, OptionCardGroup,
TextArea, TextArea,
TextInput, TextInput,
} from "@/components/form"; } from '@/components/form';
import { useShellBridge } from "@/platform/shell/route-controllers"; import { getShellProfileContext } from '@/platform/shell/bridge';
import { useShellBridge } from '@/platform/shell/route-controllers';
import type { IssuePriority, IssueReportPayload } from "../-issues.types"; import type { IssuePriority, IssueReportPayload } from '../-issues.types';
import { import {
REFRESH_EVENT, REFRESH_EVENT,
@ -26,10 +26,10 @@ import {
createIssue, createIssue,
getIssueCategoriesForEntity, getIssueCategoriesForEntity,
issueCountsQueryOptions, issueCountsQueryOptions,
} from "../-issues.helpers"; } from '../-issues.helpers';
import styles from "./issue-detail-modal.module.css"; 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 { interface ReportIssueFormValues {
category: string; category: string;
@ -39,10 +39,10 @@ interface ReportIssueFormValues {
} }
const DEFAULT_REPORT_ISSUE_VALUES: ReportIssueFormValues = { const DEFAULT_REPORT_ISSUE_VALUES: ReportIssueFormValues = {
category: "", category: '',
description: "", description: '',
priority: "normal", priority: 'normal',
title: "", title: '',
}; };
export function IssueDomainHost() { export function IssueDomainHost() {
@ -127,7 +127,7 @@ function ReportIssueModal({
[payload.entityType], [payload.entityType],
); );
const entityLabel = const entityLabel =
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 (values: ReportIssueFormValues) => { mutationFn: async (values: ReportIssueFormValues) => {
@ -141,7 +141,7 @@ function ReportIssueModal({
}); });
}, },
onSuccess: () => { onSuccess: () => {
notify("Issue reported successfully", "success"); notify('Issue reported successfully', 'success');
onSubmitted(); onSubmitted();
}, },
}); });
@ -161,9 +161,9 @@ function ReportIssueModal({
await createMutation.mutateAsync(normalizedValues); await createMutation.mutateAsync(normalizedValues);
} catch (mutationError) { } catch (mutationError) {
const message = const message =
mutationError instanceof Error ? mutationError.message : "Failed to submit issue"; mutationError instanceof Error ? mutationError.message : 'Failed to submit issue';
formApi.setErrorMap({ onSubmit: message }); formApi.setErrorMap({ onSubmit: message });
notify(message, "error"); notify(message, 'error');
throw mutationError; throw mutationError;
} }
}, },
@ -206,7 +206,7 @@ function ReportIssueModal({
{payload.artistName ? ( {payload.artistName ? (
<div className={styles.reportIssueEntityArtist}> <div className={styles.reportIssueEntityArtist}>
{payload.artistName} {payload.artistName}
{payload.albumTitle ? ` - ${payload.albumTitle}` : ""} {payload.albumTitle ? ` - ${payload.albumTitle}` : ''}
</div> </div>
) : null} ) : null}
</div> </div>
@ -227,9 +227,9 @@ function ReportIssueModal({
field.handleChange(category); field.handleChange(category);
createMutation.reset(); createMutation.reset();
form.setErrorMap({ onSubmit: undefined }); form.setErrorMap({ onSubmit: undefined });
if (!form.getFieldMeta("title")?.isTouched) { if (!form.getFieldMeta('title')?.isTouched) {
form.setFieldValue( form.setFieldValue(
"title", 'title',
createDefaultIssueTitle(category, payload.entityName), createDefaultIssueTitle(category, payload.entityName),
{ dontUpdateMeta: true }, { dontUpdateMeta: true },
); );
@ -298,7 +298,7 @@ function ReportIssueModal({
label="Priority" label="Priority"
> >
<OptionButtonGroup> <OptionButtonGroup>
{(["low", "normal", "high"] as const).map((priority) => ( {(['low', 'normal', 'high'] as const).map((priority) => (
<OptionButton <OptionButton
key={priority} key={priority}
onClick={() => field.handleChange(priority)} onClick={() => field.handleChange(priority)}
@ -326,11 +326,7 @@ function ReportIssueModal({
</div> </div>
<FormActions className={styles.modalFooter}> <FormActions className={styles.modalFooter}>
<Button <Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
className={styles.modalButtonSecondary}
type="button"
onClick={onClose}
>
Cancel Cancel
</Button> </Button>
<form.Subscribe <form.Subscribe
@ -348,7 +344,7 @@ function ReportIssueModal({
type="submit" type="submit"
disabled={!state.category || !state.title.trim() || isSubmitting} disabled={!state.category || !state.title.trim() || isSubmitting}
> >
{isSubmitting ? "Submitting..." : "Submit Issue"} {isSubmitting ? 'Submitting...' : 'Submit Issue'}
</Button> </Button>
); );
}} }}
@ -373,27 +369,27 @@ function validateReportIssueForm(
values: ReportIssueFormValues, values: ReportIssueFormValues,
): string | undefined { ): string | undefined {
const normalizedValues = normalizeReportIssueFormValues(values); const normalizedValues = normalizeReportIssueFormValues(values);
if (!profileId) return "Profile is still loading"; if (!profileId) return 'Profile is still loading';
if (!normalizedValues.category) return "Please select an issue category"; if (!normalizedValues.category) return 'Please select an issue category';
if (!normalizedValues.title) return "Please provide a title for the issue"; if (!normalizedValues.title) return 'Please provide a title for the issue';
return undefined; return undefined;
} }
function getReportIssueFormError(errors: Array<unknown>): string { function getReportIssueFormError(errors: Array<unknown>): string {
const error = errors.find(Boolean); const error = errors.find(Boolean);
if (!error) return ""; if (!error) return '';
if (typeof error === "string") return error; if (typeof error === 'string') return error;
if (error instanceof Error) return error.message; if (error instanceof Error) return error.message;
return String(error); 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);
} }
function updateBadge(openCount: number) { function updateBadge(openCount: number) {
const badge = document.getElementById("issues-nav-badge"); const badge = document.getElementById('issues-nav-badge');
if (!badge) return; if (!badge) return;
badge.textContent = String(openCount || 0); badge.textContent = String(openCount || 0);
badge.classList.toggle("hidden", !openCount); badge.classList.toggle('hidden', !openCount);
} }

View file

@ -2,8 +2,8 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getShellProfileContext } from '@/platform/shell/bridge';
import { Select } from '@/components/form'; import { Select } from '@/components/form';
import { getShellProfileContext } from '@/platform/shell/bridge';
import { useReactPageShell } from '@/platform/shell/route-controllers'; import { useReactPageShell } from '@/platform/shell/route-controllers';
import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types'; import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types';

View file

@ -18,6 +18,7 @@ export default defineConfig({
], ],
}, },
sortPackageJson: true, sortPackageJson: true,
trailingComma: 'all',
}, },
plugins: [ plugins: [
tanstackRouter({ tanstackRouter({