Use Base UI and clsx in form primitives
- Adopt Base UI for the shared form field, input, button, and toggle wrappers - Replace the local class-name helper with clsx to keep the primitives simpler - Keep native textarea and select controls where they still fit the existing styling pattern
This commit is contained in:
parent
f06ccd643e
commit
bd6be61b77
13 changed files with 266 additions and 128 deletions
2
dev.py
2
dev.py
|
|
@ -318,7 +318,7 @@ def watch_and_run_backend() -> None:
|
|||
def main() -> int:
|
||||
if not (ROOT_DIR / 'webui' / 'node_modules').is_dir():
|
||||
print('webui/node_modules is missing.')
|
||||
print('Run: cd webui && npm install')
|
||||
print('Run: cd webui && npm ci')
|
||||
return 1
|
||||
|
||||
vite_proc = start_vite()
|
||||
|
|
|
|||
120
webui/package-lock.json
generated
120
webui/package-lock.json
generated
|
|
@ -6,9 +6,11 @@
|
|||
"": {
|
||||
"name": "soulsync-webui",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@tanstack/react-form": "^1.29.1",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"@tanstack/react-router": "^1.168.24",
|
||||
"clsx": "^2.1.1",
|
||||
"ky": "^2.0.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
|
|
@ -338,7 +340,6 @@
|
|||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
|
|
@ -392,6 +393,66 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@base-ui/react": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.4.1.tgz",
|
||||
"integrity": "sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@base-ui/utils": "0.2.8",
|
||||
"@floating-ui/react-dom": "^2.1.8",
|
||||
"@floating-ui/utils": "^0.2.11",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@date-fns/tz": "^1.2.0",
|
||||
"@types/react": "^17 || ^18 || ^19",
|
||||
"date-fns": "^4.0.0",
|
||||
"react": "^17 || ^18 || ^19",
|
||||
"react-dom": "^17 || ^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@date-fns/tz": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@base-ui/utils": {
|
||||
"version": "0.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.8.tgz",
|
||||
"integrity": "sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@floating-ui/utils": "^0.2.11",
|
||||
"reselect": "^5.1.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17 || ^18 || ^19",
|
||||
"react": "^17 || ^18 || ^19",
|
||||
"react-dom": "^17 || ^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
|
|
@ -563,6 +624,44 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
|
||||
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@inquirer/ansi": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz",
|
||||
|
|
@ -2001,7 +2100,7 @@
|
|||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -2631,6 +2730,15 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -2703,7 +2811,7 @@
|
|||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
|
|
@ -3952,6 +4060,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rettime": {
|
||||
"version": "0.11.8",
|
||||
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.8.tgz",
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@
|
|||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@tanstack/react-form": "^1.29.1",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"@tanstack/react-router": "^1.168.24",
|
||||
"clsx": "^2.1.1",
|
||||
"ky": "^2.0.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { describe, expect, it, vi } from 'vite-plus/test';
|
||||
import type { ResponsePromise } from 'ky';
|
||||
|
||||
import { HTTPError } from 'ky';
|
||||
|
||||
import type { ResponsePromise } from 'ky';
|
||||
import { describe, expect, it, vi } from 'vite-plus/test';
|
||||
|
||||
import { readJson } from './api-client';
|
||||
|
||||
|
|
@ -46,9 +45,6 @@ describe('readJson', () => {
|
|||
const result = readJson(promise);
|
||||
|
||||
await expect(result).rejects.toBe(error);
|
||||
await expect(result).rejects.toHaveProperty(
|
||||
'message',
|
||||
error.message,
|
||||
);
|
||||
await expect(result).rejects.toHaveProperty('message', error.message);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { Button as BaseButton } from '@base-ui/react/button';
|
||||
import { Field } from '@base-ui/react/field';
|
||||
import { Input as BaseInput } from '@base-ui/react/input';
|
||||
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ButtonHTMLAttributes,
|
||||
type InputHTMLAttributes,
|
||||
type SelectHTMLAttributes,
|
||||
type ReactNode,
|
||||
type TextareaHTMLAttributes,
|
||||
|
|
@ -9,10 +14,6 @@ import {
|
|||
|
||||
import styles from './form.module.css';
|
||||
|
||||
function mergeClassNames(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export interface FormFieldProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
|
|
@ -31,30 +32,36 @@ export function FormField({
|
|||
label,
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<div className={mergeClassNames(styles.field, className)}>
|
||||
<Field.Root className={clsx(styles.field, className)}>
|
||||
<div className={styles.fieldHeader}>
|
||||
{htmlFor ? (
|
||||
<label className={styles.fieldLabel} htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
) : (
|
||||
<div className={styles.fieldLabel}>{label}</div>
|
||||
<Field.Label className={styles.fieldLabel}>{label}</Field.Label>
|
||||
)}
|
||||
{helperText ? <div className={styles.fieldHelper}>{helperText}</div> : null}
|
||||
{helperText ? (
|
||||
<Field.Description className={styles.fieldHelper}>{helperText}</Field.Description>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.fieldControl}>{children}</div>
|
||||
{error ? <FormError message={error} /> : null}
|
||||
</div>
|
||||
</Field.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||
type BaseInputProps = ComponentPropsWithoutRef<typeof BaseInput>;
|
||||
|
||||
export type TextInputProps = Omit<BaseInputProps, 'className'> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
|
||||
{ className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return <input ref={ref} className={mergeClassNames(styles.textInput, className)} {...props} />;
|
||||
return <BaseInput ref={ref} className={clsx(styles.textInput, className)} {...props} />;
|
||||
});
|
||||
|
||||
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
|
@ -63,7 +70,7 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
|
|||
{ className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return <textarea ref={ref} className={mergeClassNames(styles.textArea, className)} {...props} />;
|
||||
return <textarea ref={ref} className={clsx(styles.textArea, className)} {...props} />;
|
||||
});
|
||||
|
||||
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
|
||||
|
|
@ -72,7 +79,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|||
{ className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return <select ref={ref} className={mergeClassNames(styles.select, className)} {...props} />;
|
||||
return <select ref={ref} className={clsx(styles.select, className)} {...props} />;
|
||||
});
|
||||
|
||||
export function OptionCardGroup({
|
||||
|
|
@ -82,14 +89,19 @@ export function OptionCardGroup({
|
|||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={mergeClassNames(styles.optionCardGroup, className)}>{children}</div>;
|
||||
return <div className={clsx(styles.optionCardGroup, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export interface OptionCardProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
|
||||
export interface OptionCardProps extends Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'title' | 'value'
|
||||
> {
|
||||
className?: string;
|
||||
description?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
selected?: boolean;
|
||||
title?: ReactNode;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(function OptionCard(
|
||||
|
|
@ -97,14 +109,10 @@ export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(functio
|
|||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
<BaseToggle
|
||||
ref={ref}
|
||||
aria-pressed={selected}
|
||||
className={mergeClassNames(
|
||||
styles.optionCard,
|
||||
selected && styles.optionCardSelected,
|
||||
className,
|
||||
)}
|
||||
pressed={selected}
|
||||
className={clsx(styles.optionCard, selected && styles.optionCardSelected, className)}
|
||||
type={type}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -117,7 +125,7 @@ export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(functio
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</BaseToggle>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -128,11 +136,13 @@ export function OptionButtonGroup({
|
|||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={mergeClassNames(styles.optionButtonGroup, className)}>{children}</div>;
|
||||
return <div className={clsx(styles.optionButtonGroup, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export interface OptionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton(
|
||||
|
|
@ -140,48 +150,41 @@ export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(fun
|
|||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
<BaseToggle
|
||||
ref={ref}
|
||||
aria-pressed={selected}
|
||||
className={mergeClassNames(
|
||||
styles.optionButton,
|
||||
selected && styles.optionButtonSelected,
|
||||
className,
|
||||
)}
|
||||
pressed={selected}
|
||||
className={clsx(styles.optionButton, selected && styles.optionButtonSelected, className)}
|
||||
type={type}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</BaseToggle>
|
||||
);
|
||||
});
|
||||
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
type BaseButtonProps = ComponentPropsWithoutRef<typeof BaseButton>;
|
||||
|
||||
export type ButtonProps = Omit<BaseButtonProps, 'className'> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ className, type = 'button', ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={mergeClassNames(styles.button, className)}
|
||||
type={type}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <BaseButton ref={ref} className={clsx(styles.button, className)} type={type} {...props} />;
|
||||
});
|
||||
|
||||
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div className={mergeClassNames(styles.formError, className)} role="alert">
|
||||
<div className={clsx(styles.formError, className)} role="alert">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormActions({ className, children }: { children: ReactNode; className?: string }) {
|
||||
return <div className={mergeClassNames(styles.formActions, className)}>{children}</div>;
|
||||
return <div className={clsx(styles.formActions, className)}>{children}</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { queryOptions } from '@tanstack/react-query';
|
|||
|
||||
import { apiClient, readJson } from '@/app/api-client';
|
||||
|
||||
import type { NormalizedIssuesSearch } from './-issues.helpers';
|
||||
import type {
|
||||
CreateIssuePayload,
|
||||
IssueCounts,
|
||||
|
|
@ -11,8 +12,6 @@ import type {
|
|||
IssueRecord,
|
||||
} from './-issues.types';
|
||||
|
||||
import type { NormalizedIssuesSearch } from './-issues.helpers';
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
function createIssueHeaders(profileId: number, extra?: HeadersInit): Headers {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import type {
|
||||
IssueRecord,
|
||||
IssuesSearch,
|
||||
IssueSnapshot,
|
||||
} from './-issues.types';
|
||||
import type { IssueRecord, IssuesSearch, IssueSnapshot } from './-issues.types';
|
||||
|
||||
export const REFRESH_EVENT = 'ss:issues-refresh';
|
||||
export const CLOSE_EVENT = 'ss:issues-close-detail';
|
||||
|
|
@ -116,15 +112,13 @@ export function normalizeIssuesSearch(search: IssuesSearch | undefined): Normali
|
|||
status === 'dismissed'
|
||||
? status
|
||||
: DEFAULT_ISSUES_SEARCH.status,
|
||||
category: typeof category === 'string' && ISSUE_CATEGORY_KEYS.has(category)
|
||||
? category
|
||||
: 'all',
|
||||
category: typeof category === 'string' && ISSUE_CATEGORY_KEYS.has(category) ? category : 'all',
|
||||
issueId:
|
||||
(typeof issueId === 'number' && Number.isInteger(issueId) && issueId > 0)
|
||||
typeof issueId === 'number' && Number.isInteger(issueId) && issueId > 0
|
||||
? issueId
|
||||
: (typeof issueId === 'string' && /^[1-9]\d*$/.test(issueId)
|
||||
? Number(issueId)
|
||||
: undefined),
|
||||
: typeof issueId === 'string' && /^[1-9]\d*$/.test(issueId)
|
||||
? Number(issueId)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export interface IssueSnapshot {
|
|||
track_deezer_id?: string;
|
||||
artist_tidal_id?: string;
|
||||
album_tidal_id?: string;
|
||||
artist_qobuz_id?: string | number;
|
||||
album_qobuz_id?: string | number;
|
||||
}
|
||||
|
||||
export interface IssueRecord {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,13 @@ describe('issues route', () => {
|
|||
if (url.includes('/api/issues/counts')) {
|
||||
return createResponse({
|
||||
success: true,
|
||||
counts: { open: 2, in_progress: 1, resolved: 0, dismissed: 0, total: 3 },
|
||||
counts: {
|
||||
open: 2,
|
||||
in_progress: 1,
|
||||
resolved: 0,
|
||||
dismissed: 0,
|
||||
total: 3,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/issues?')) {
|
||||
|
|
@ -105,18 +111,18 @@ describe('issues route', () => {
|
|||
status: 'open',
|
||||
priority: 'normal',
|
||||
snapshot_data: {
|
||||
title: 'Album Name',
|
||||
artist_name: 'Artist',
|
||||
thumb_url: 'https://example.com/thumb.jpg',
|
||||
spotify_album_id: 'abc123',
|
||||
track_number: 1,
|
||||
duration: 245,
|
||||
format: 'FLAC',
|
||||
bitrate: 1411,
|
||||
},
|
||||
created_at: '2026-04-03 10:30:00',
|
||||
reporter_name: 'Ada',
|
||||
title: 'Album Name',
|
||||
artist_name: 'Artist',
|
||||
thumb_url: 'https://example.com/thumb.jpg',
|
||||
spotify_album_id: 'abc123',
|
||||
track_number: 1,
|
||||
duration: 245,
|
||||
format: 'FLAC',
|
||||
bitrate: 1411,
|
||||
},
|
||||
created_at: '2026-04-03 10:30:00',
|
||||
reporter_name: 'Ada',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/spotify/album/abc123')) {
|
||||
|
|
@ -151,6 +157,10 @@ describe('issues route', () => {
|
|||
it('loads the detail modal from the route search state', async () => {
|
||||
renderIssuesRoute(['/issues?issueId=7']);
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
|
||||
expect(await screen.findByTitle('Spotify Album')).toHaveAttribute(
|
||||
'href',
|
||||
'https://open.spotify.com/album/abc123',
|
||||
);
|
||||
});
|
||||
|
||||
it('stores filters in route search state', async () => {
|
||||
|
|
@ -186,7 +196,9 @@ describe('issues route', () => {
|
|||
renderIssuesRoute();
|
||||
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
||||
|
||||
const closeButton = await screen.findByRole('button', { name: /close issue detail/i });
|
||||
const closeButton = await screen.findByRole('button', {
|
||||
name: /close issue detail/i,
|
||||
});
|
||||
const deleteButton = await screen.findByRole('button', { name: /delete/i });
|
||||
|
||||
deleteButton.focus();
|
||||
|
|
@ -240,7 +252,9 @@ describe('issues route', () => {
|
|||
|
||||
fireEvent.change(titleInput, { target: { value: 'Custom report title' } });
|
||||
fireEvent.blur(titleInput);
|
||||
fireEvent.change(descriptionInput, { target: { value: 'Detailed reproduction notes' } });
|
||||
fireEvent.change(descriptionInput, {
|
||||
target: { value: 'Detailed reproduction notes' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /high/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /wrong metadata/i }));
|
||||
expect(titleInput).toHaveValue('Custom report title');
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
|
||||
import type { IssueRecord } from '../-issues.types';
|
||||
|
||||
import { deleteIssue, updateIssue } from '../-issues.api';
|
||||
import {
|
||||
formatIssueDate,
|
||||
formatStatusLabel,
|
||||
|
|
@ -17,7 +18,6 @@ import {
|
|||
ISSUE_CATEGORY_META,
|
||||
parseSnapshot,
|
||||
} from '../-issues.helpers';
|
||||
import { deleteIssue, updateIssue } from '../-issues.api';
|
||||
import styles from './issue-detail-modal.module.css';
|
||||
|
||||
export function IssueDetailModal({
|
||||
|
|
@ -51,9 +51,8 @@ export function IssueDetailModal({
|
|||
return;
|
||||
}
|
||||
|
||||
previouslyFocusedElementRef.current = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
previouslyFocusedElementRef.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
|
||||
const focusModal = () => {
|
||||
const modal = modalRef.current;
|
||||
|
|
@ -213,7 +212,11 @@ export function IssueDetailModal({
|
|||
className={styles.modalButtonReopen}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateMutation.mutate({ issueId: issue.id, status: 'open', adminResponse })
|
||||
updateMutation.mutate({
|
||||
issueId: issue.id,
|
||||
status: 'open',
|
||||
adminResponse,
|
||||
})
|
||||
}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
|
|
@ -381,7 +384,9 @@ export function IssueDetailModal({
|
|||
|
||||
<div className={styles.issueDetailInfoBar}>
|
||||
<div className={styles.issueDetailInfoLeft}>
|
||||
<span className={`${styles.issuePriorityDot} ${getPriorityDotClassName(priorityClassName)}`} />
|
||||
<span
|
||||
className={`${styles.issuePriorityDot} ${getPriorityDotClassName(priorityClassName)}`}
|
||||
/>
|
||||
<span
|
||||
className={`${styles.issueStatusBadge} ${getStatusClassName(issue.status)}`}
|
||||
>
|
||||
|
|
@ -465,11 +470,12 @@ export function IssueDetailModal({
|
|||
{trackRows.length > 0 ? (
|
||||
<div className={styles.issueDetailSection}>
|
||||
<div className={styles.issueDetailSectionTitle}>
|
||||
Track Listing <span className={styles.issueDetailSectionCount}>{trackRows.length} tracks</span>
|
||||
</div>
|
||||
<div className={styles.issueDetailTracklist}>
|
||||
{renderTrackListing(trackRows)}
|
||||
Track Listing{' '}
|
||||
<span className={styles.issueDetailSectionCount}>
|
||||
{trackRows.length} tracks
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.issueDetailTracklist}>{renderTrackListing(trackRows)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -564,13 +570,12 @@ function renderTrackListing(trackRows: Array<Record<string, unknown>>) {
|
|||
const formatClassName = getTrackFormatClassName(format);
|
||||
const bitrateClassName = getTrackBitrateClassName(bitrateValue, format);
|
||||
nodes.push(
|
||||
<div className={styles.issueDetailTracklistRow} key={String(track.id || `${track.title}-${index}`)}>
|
||||
<span className={styles.issueDetailTracklistNum}>
|
||||
{String(track.track_number || '-')}
|
||||
</span>
|
||||
<span className={styles.issueDetailTracklistTitle}>
|
||||
{String(track.title || 'Unknown')}
|
||||
</span>
|
||||
<div
|
||||
className={styles.issueDetailTracklistRow}
|
||||
key={String(track.id || `${track.title}-${index}`)}
|
||||
>
|
||||
<span className={styles.issueDetailTracklistNum}>{String(track.track_number || '-')}</span>
|
||||
<span className={styles.issueDetailTracklistTitle}>{String(track.title || 'Unknown')}</span>
|
||||
<span className={styles.issueDetailTracklistDur}>{duration}</span>
|
||||
<span className={styles.issueDetailTracklistMeta}>
|
||||
{format ? (
|
||||
|
|
@ -630,21 +635,19 @@ function formatDuration(value: unknown): string {
|
|||
}
|
||||
|
||||
function getExternalLinks(snapshot: ReturnType<typeof parseSnapshot>) {
|
||||
const links: Array<
|
||||
| {
|
||||
className:
|
||||
| 'issueExternalLinkSpotify'
|
||||
| 'issueExternalLinkMusicBrainz'
|
||||
| 'issueExternalLinkDeezer'
|
||||
| 'issueExternalLinkTidal'
|
||||
| 'issueExternalLinkQobuz';
|
||||
id?: string | number;
|
||||
label: string;
|
||||
service: string;
|
||||
type: string;
|
||||
url?: string;
|
||||
}
|
||||
> = [];
|
||||
const links: Array<{
|
||||
className:
|
||||
| 'issueExternalLinkSpotify'
|
||||
| 'issueExternalLinkMusicBrainz'
|
||||
| 'issueExternalLinkDeezer'
|
||||
| 'issueExternalLinkTidal'
|
||||
| 'issueExternalLinkQobuz';
|
||||
id?: string | number;
|
||||
label: string;
|
||||
service: string;
|
||||
type: string;
|
||||
url?: string;
|
||||
}> = [];
|
||||
if (snapshot.spotify_artist_id) {
|
||||
links.push({
|
||||
className: 'issueExternalLinkSpotify',
|
||||
|
|
@ -789,15 +792,27 @@ function getAlbumMetaParts(
|
|||
function getTrackMetaItems(snapshot: ReturnType<typeof parseSnapshot>) {
|
||||
const items: Array<{ icon: string; label: string; value: string }> = [];
|
||||
if (snapshot.track_number) {
|
||||
items.push({ icon: '#', label: 'Track', value: String(snapshot.track_number) });
|
||||
items.push({
|
||||
icon: '#',
|
||||
label: 'Track',
|
||||
value: String(snapshot.track_number),
|
||||
});
|
||||
}
|
||||
const duration = formatDuration(snapshot.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.bitrate)
|
||||
items.push({ icon: 'B', label: 'Bitrate', value: `${snapshot.bitrate} kbps` });
|
||||
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.quality)
|
||||
items.push({ icon: 'Q', label: 'Quality', value: String(snapshot.quality) });
|
||||
items.push({
|
||||
icon: 'Q',
|
||||
label: 'Quality',
|
||||
value: String(snapshot.quality),
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,15 +20,12 @@ import { useShellBridge } from '@/platform/shell/route-controllers';
|
|||
|
||||
import type { IssuePriority, IssueReportPayload } from '../-issues.types';
|
||||
|
||||
import { createIssue, issueCountsQueryOptions } from '../-issues.api';
|
||||
import {
|
||||
REFRESH_EVENT,
|
||||
createDefaultIssueTitle,
|
||||
getIssueCategoriesForEntity,
|
||||
} from '../-issues.helpers';
|
||||
import {
|
||||
createIssue,
|
||||
issueCountsQueryOptions,
|
||||
} from '../-issues.api';
|
||||
import styles from './issue-detail-modal.module.css';
|
||||
|
||||
const ISSUE_DOMAIN_QUERY_KEY = ['issues'] as const;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import { useReactPageShell } from '@/platform/shell/route-controllers';
|
|||
|
||||
import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types';
|
||||
|
||||
import {
|
||||
issueCountsQueryOptions,
|
||||
issueDetailQueryOptions,
|
||||
issueListQueryOptions,
|
||||
} from '../-issues.api';
|
||||
import {
|
||||
CLOSE_EVENT,
|
||||
REFRESH_EVENT,
|
||||
|
|
@ -23,11 +28,6 @@ import {
|
|||
normalizeIssuesSearch,
|
||||
parseSnapshot,
|
||||
} from '../-issues.helpers';
|
||||
import {
|
||||
issueCountsQueryOptions,
|
||||
issueDetailQueryOptions,
|
||||
issueListQueryOptions,
|
||||
} from '../-issues.api';
|
||||
import { Route } from '../route';
|
||||
import { IssueDetailModal } from './issue-detail-modal';
|
||||
import styles from './issues-page.module.css';
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ export const Route = createFileRoute('/issues')({
|
|||
context.queryClient.ensureQueryData(issueCountsQueryOptions(profile.profileId)),
|
||||
context.queryClient.ensureQueryData(issueListQueryOptions(profile.profileId, deps)),
|
||||
deps.issueId
|
||||
? context.queryClient.ensureQueryData(issueDetailQueryOptions(profile.profileId, deps.issueId))
|
||||
? context.queryClient.ensureQueryData(
|
||||
issueDetailQueryOptions(profile.profileId, deps.issueId),
|
||||
)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue