Add shared form primitives for issues
- Introduce reusable input, textarea, card, button, and action components - Use them in the issue report composer as the reference implementation - Keep the TanStack form logic at the usage site and add focused regression coverage
This commit is contained in:
parent
48aec3f6f3
commit
a19514dae9
8 changed files with 628 additions and 184 deletions
191
webui/src/components/form/form.module.css
Normal file
191
webui/src/components/form/form.module.css
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldLabel {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldHelper {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldControl {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput,
|
||||||
|
.textArea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 12px 14px;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput::placeholder,
|
||||||
|
.textArea::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput:hover,
|
||||||
|
.textArea:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.16);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput:focus,
|
||||||
|
.textArea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.55);
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textArea {
|
||||||
|
min-height: 128px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionCardGroup {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.03)),
|
||||||
|
rgba(255, 255, 255, 0.03);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionCard:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.04)),
|
||||||
|
rgba(255, 255, 255, 0.04);
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionCardSelected {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.45);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(255, 255, 255, 0.04)),
|
||||||
|
rgba(255, 255, 255, 0.05);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.1), 0 14px 32px rgba(0, 0, 0, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionCardIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionCardBody {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionCardTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionCardDescription {
|
||||||
|
color: rgba(255, 255, 255, 0.48);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionButtonGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionButton {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionButton:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionButtonSelected {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.5);
|
||||||
|
background: rgba(var(--accent-rgb), 0.18);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formError {
|
||||||
|
color: #ff8d8d;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
106
webui/src/components/form/form.test.tsx
Normal file
106
webui/src/components/form/form.test.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FormActions,
|
||||||
|
FormError,
|
||||||
|
FormField,
|
||||||
|
OptionButton,
|
||||||
|
OptionButtonGroup,
|
||||||
|
OptionCard,
|
||||||
|
OptionCardGroup,
|
||||||
|
TextArea,
|
||||||
|
TextInput,
|
||||||
|
} from './form';
|
||||||
|
|
||||||
|
function FormDemo() {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [details, setDetails] = useState('');
|
||||||
|
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
|
||||||
|
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<FormField label="Title" helperText="Short summary" htmlFor="title-input">
|
||||||
|
<TextInput
|
||||||
|
id="title-input"
|
||||||
|
placeholder="Enter title"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Details" helperText="Longer explanation" htmlFor="details-input">
|
||||||
|
<TextArea
|
||||||
|
id="details-input"
|
||||||
|
placeholder="Enter details"
|
||||||
|
rows={3}
|
||||||
|
value={details}
|
||||||
|
onChange={(event) => setDetails(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Category" helperText="Pick one">
|
||||||
|
<OptionCardGroup>
|
||||||
|
<OptionCard
|
||||||
|
description="Album art is wrong"
|
||||||
|
icon="🖼️"
|
||||||
|
onClick={() => setCategory('wrong_cover')}
|
||||||
|
selected={category === 'wrong_cover'}
|
||||||
|
title="Wrong Cover"
|
||||||
|
/>
|
||||||
|
<OptionCard
|
||||||
|
description="Metadata needs fixing"
|
||||||
|
icon="🏷️"
|
||||||
|
onClick={() => setCategory('wrong_metadata')}
|
||||||
|
selected={category === 'wrong_metadata'}
|
||||||
|
title="Wrong Metadata"
|
||||||
|
/>
|
||||||
|
</OptionCardGroup>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Priority" helperText="Set urgency">
|
||||||
|
<OptionButtonGroup>
|
||||||
|
{(['low', 'normal', 'high'] as const).map((value) => (
|
||||||
|
<OptionButton key={value} onClick={() => setPriority(value)} selected={priority === value}>
|
||||||
|
{value[0].toUpperCase()}
|
||||||
|
{value.slice(1)}
|
||||||
|
</OptionButton>
|
||||||
|
))}
|
||||||
|
</OptionButtonGroup>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormError message="Validation failed" />
|
||||||
|
|
||||||
|
<FormActions>
|
||||||
|
<button type="button">Cancel</button>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</FormActions>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('form primitives', () => {
|
||||||
|
it('render accessible controls and support selection state', () => {
|
||||||
|
render(<FormDemo />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Title')).toHaveAttribute('placeholder', 'Enter title');
|
||||||
|
expect(screen.getByLabelText('Details')).toHaveAttribute('placeholder', 'Enter details');
|
||||||
|
expect(screen.getByText('Short summary')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
|
||||||
|
|
||||||
|
const wrongCover = screen.getByRole('button', { name: /wrong cover/i });
|
||||||
|
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
|
||||||
|
expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(wrongMetadata).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
fireEvent.click(wrongMetadata);
|
||||||
|
expect(wrongCover).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
expect(wrongMetadata).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
const highPriority = screen.getByRole('button', { name: 'High' });
|
||||||
|
expect(highPriority).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
fireEvent.click(highPriority);
|
||||||
|
expect(highPriority).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
168
webui/src/components/form/form.tsx
Normal file
168
webui/src/components/form/form.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
type ButtonHTMLAttributes,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
type TextareaHTMLAttributes,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
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;
|
||||||
|
error?: ReactNode;
|
||||||
|
helperText?: ReactNode;
|
||||||
|
htmlFor?: string;
|
||||||
|
label: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormField({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
htmlFor,
|
||||||
|
label,
|
||||||
|
}: FormFieldProps) {
|
||||||
|
return (
|
||||||
|
<div className={mergeClassNames(styles.field, className)}>
|
||||||
|
<div className={styles.fieldHeader}>
|
||||||
|
{htmlFor ? (
|
||||||
|
<label className={styles.fieldLabel} htmlFor={htmlFor}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<div className={styles.fieldLabel}>{label}</div>
|
||||||
|
)}
|
||||||
|
{helperText ? <div className={styles.fieldHelper}>{helperText}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldControl}>{children}</div>
|
||||||
|
{error ? <FormError message={error} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
|
||||||
|
{ className, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return <input ref={ref} className={mergeClassNames(styles.textInput, className)} {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
|
|
||||||
|
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
|
||||||
|
{ className, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return <textarea ref={ref} className={mergeClassNames(styles.textArea, className)} {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
export function OptionCardGroup({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return <div className={mergeClassNames(styles.optionCardGroup, className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OptionCardProps
|
||||||
|
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
|
||||||
|
description?: ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
selected?: boolean;
|
||||||
|
title?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(function OptionCard(
|
||||||
|
{ className, children, description, icon, selected = false, title, type = 'button', ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={mergeClassNames(
|
||||||
|
styles.optionCard,
|
||||||
|
selected && styles.optionCardSelected,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
type={type}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
{icon ? <div className={styles.optionCardIcon}>{icon}</div> : null}
|
||||||
|
<div className={styles.optionCardBody}>
|
||||||
|
{title ? <div className={styles.optionCardTitle}>{title}</div> : null}
|
||||||
|
{description ? <div className={styles.optionCardDescription}>{description}</div> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function OptionButtonGroup({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return <div className={mergeClassNames(styles.optionButtonGroup, className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OptionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton(
|
||||||
|
{ className, children, selected = false, type = 'button', ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={mergeClassNames(
|
||||||
|
styles.optionButton,
|
||||||
|
selected && styles.optionButtonSelected,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
type={type}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={mergeClassNames(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>;
|
||||||
|
}
|
||||||
11
webui/src/components/form/index.ts
Normal file
11
webui/src/components/form/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export {
|
||||||
|
FormActions,
|
||||||
|
FormError,
|
||||||
|
FormField,
|
||||||
|
OptionButton,
|
||||||
|
OptionButtonGroup,
|
||||||
|
OptionCard,
|
||||||
|
OptionCardGroup,
|
||||||
|
TextArea,
|
||||||
|
TextInput,
|
||||||
|
} from './form';
|
||||||
|
|
@ -180,10 +180,26 @@ 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 }));
|
||||||
const titleInput = screen.getByLabelText(/title/i);
|
const titleInput = screen.getByLabelText(/title/i);
|
||||||
|
const descriptionInput = screen.getByLabelText(/details/i);
|
||||||
|
const submitButton = screen.getByRole('button', { name: /submit issue/i });
|
||||||
|
const form = submitButton.closest('form');
|
||||||
|
|
||||||
expect(titleInput).toHaveValue('Wrong Cover Art: Album Name');
|
expect(titleInput).toHaveValue('Wrong Cover Art: Album Name');
|
||||||
|
fireEvent.change(titleInput, { target: { value: '' } });
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
fireEvent.submit(form!);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Please provide a title for the issue');
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.change(titleInput, { target: { value: 'Custom report title' } });
|
fireEvent.change(titleInput, { target: { value: 'Custom report title' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: /missing tracks/i }));
|
fireEvent.blur(titleInput);
|
||||||
|
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');
|
expect(titleInput).toHaveValue('Custom report title');
|
||||||
|
expect(descriptionInput).toHaveValue('Detailed reproduction notes');
|
||||||
|
expect(screen.getByRole('button', { name: /high/i })).toHaveAttribute('aria-pressed', 'true');
|
||||||
fireEvent.click(screen.getByRole('button', { name: /submit issue/i }));
|
fireEvent.click(screen.getByRole('button', { name: /submit issue/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
|
||||||
|
|
@ -828,92 +828,6 @@
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reportIssueCategoryGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reportIssueCategoryCard {
|
|
||||||
display: grid;
|
|
||||||
gap: 5px;
|
|
||||||
min-height: 122px;
|
|
||||||
text-align: left;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reportIssueCategoryCard:hover,
|
|
||||||
.reportIssueCategoryCardSelected {
|
|
||||||
border-color: rgba(var(--accent-light-rgb), 0.45);
|
|
||||||
background: rgba(var(--accent-light-rgb), 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reportIssueCategoryIcon {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reportIssueCategoryLabel {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reportIssueCategoryDesc {
|
|
||||||
color: rgba(255, 255, 255, 0.48);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reportIssueInput {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
color: #fff;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
outline: none;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reportIssuePriorityRow {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reportIssuePriorityButton {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: rgba(255, 255, 255, 0.72);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reportIssuePriorityButtonSelected {
|
|
||||||
border-color: rgba(var(--accent-light-rgb), 0.45);
|
|
||||||
background: rgba(var(--accent-light-rgb), 0.14);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reportIssueError {
|
|
||||||
color: #ffd0d0;
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
|
||||||
background: rgba(239, 68, 68, 0.1);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalFooter {
|
.modalFooter {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
@ -1065,6 +979,11 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issueDetailInfoLeft,
|
||||||
|
.issueDetailInfoRight {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
max-height: calc(100vh - 24px);
|
max-height: calc(100vh - 24px);
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
|
|
@ -1075,18 +994,40 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalFooter {
|
.modalFooter {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
padding: 0 18px 18px;
|
padding: 0 18px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issueDetailMetaGrid {
|
.issueDetailMetaGrid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.issueMetaValue {
|
.issueMetaValue {
|
||||||
max-width: 80px;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issueActionButtons {
|
.issueActionButtons {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issueActionButton,
|
||||||
|
.modalButton {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issueActionButton {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issueExternalLinks {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issueExternalLink {
|
||||||
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,23 @@
|
||||||
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 { getShellProfileContext } from "@/platform/shell/bridge";
|
||||||
import { useShellBridge } from '@/platform/shell/route-controllers';
|
import {
|
||||||
|
FormActions,
|
||||||
|
FormError,
|
||||||
|
FormField,
|
||||||
|
OptionButton,
|
||||||
|
OptionButtonGroup,
|
||||||
|
OptionCard,
|
||||||
|
OptionCardGroup,
|
||||||
|
TextArea,
|
||||||
|
TextInput,
|
||||||
|
} from "@/components/form";
|
||||||
|
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,
|
||||||
|
|
@ -14,10 +25,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;
|
||||||
|
|
@ -27,10 +38,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() {
|
||||||
|
|
@ -115,7 +126,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) => {
|
||||||
|
|
@ -129,7 +140,7 @@ function ReportIssueModal({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
notify('Issue reported successfully', 'success');
|
notify("Issue reported successfully", "success");
|
||||||
onSubmitted();
|
onSubmitted();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -149,9 +160,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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -194,63 +205,56 @@ 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>
|
||||||
|
|
||||||
<div className={styles.issueDetailSection}>
|
<FormField
|
||||||
<label className={styles.issueDetailSectionTitle}>What's the problem?</label>
|
label="What's the problem?"
|
||||||
<div className={styles.reportIssueCategoryGrid}>
|
helperText="Pick the closest match for the report."
|
||||||
<form.Field name="category">
|
>
|
||||||
{(field) =>
|
<form.Field name="category">
|
||||||
categories.map(([category, meta]) => (
|
{(field) => (
|
||||||
<button
|
<OptionCardGroup>
|
||||||
|
{categories.map(([category, meta]) => (
|
||||||
|
<OptionCard
|
||||||
key={category}
|
key={category}
|
||||||
className={`${styles.reportIssueCategoryCard} ${
|
description={meta.description}
|
||||||
field.state.value === category
|
icon={meta.icon}
|
||||||
? styles.reportIssueCategoryCardSelected
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
selected={field.state.value === category}
|
||||||
<div className={styles.reportIssueCategoryIcon}>{meta.icon}</div>
|
title={meta.label}
|
||||||
<div className={styles.reportIssueCategoryLabel}>{meta.label}</div>
|
/>
|
||||||
<div className={styles.reportIssueCategoryDesc}>{meta.description}</div>
|
))}
|
||||||
</button>
|
</OptionCardGroup>
|
||||||
))
|
)}
|
||||||
}
|
</form.Field>
|
||||||
</form.Field>
|
</FormField>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form.Subscribe selector={(state) => state.values.category}>
|
<form.Subscribe selector={(state) => state.values.category}>
|
||||||
{(selectedCategory) =>
|
{(selectedCategory) =>
|
||||||
selectedCategory ? (
|
selectedCategory ? (
|
||||||
<div className={styles.issueDetailSection}>
|
<>
|
||||||
<form.Field name="title">
|
<form.Field name="title">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<>
|
<FormField
|
||||||
<label
|
helperText="A short summary that makes the problem obvious."
|
||||||
className={styles.issueDetailSectionTitle}
|
htmlFor="report-issue-title-input"
|
||||||
htmlFor="report-issue-title-input"
|
label="Title"
|
||||||
>
|
>
|
||||||
Title
|
<TextInput
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className={styles.reportIssueInput}
|
|
||||||
id="report-issue-title-input"
|
id="report-issue-title-input"
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
|
|
@ -262,20 +266,18 @@ function ReportIssueModal({
|
||||||
placeholder="Brief summary of the issue..."
|
placeholder="Brief summary of the issue..."
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
/>
|
/>
|
||||||
</>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
<form.Field name="description">
|
<form.Field name="description">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<>
|
<FormField
|
||||||
<label
|
helperText="Include any details that will help triage the issue."
|
||||||
className={styles.issueDetailSectionTitle}
|
htmlFor="report-issue-desc-input"
|
||||||
htmlFor="report-issue-desc-input"
|
label="Details"
|
||||||
>
|
>
|
||||||
Details
|
<TextArea
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className={styles.issueDetailResponseTextarea}
|
|
||||||
id="report-issue-desc-input"
|
id="report-issue-desc-input"
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
|
|
@ -284,31 +286,32 @@ function ReportIssueModal({
|
||||||
rows={4}
|
rows={4}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
/>
|
/>
|
||||||
</>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
<form.Field name="priority">
|
<form.Field name="priority">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<div className={styles.reportIssuePriorityRow} aria-label="Priority">
|
<FormField
|
||||||
{(['low', 'normal', 'high'] as const).map((priority) => (
|
helperText="Set the urgency if this needs faster attention."
|
||||||
<button
|
label="Priority"
|
||||||
key={priority}
|
>
|
||||||
className={`${styles.reportIssuePriorityButton} ${
|
<OptionButtonGroup>
|
||||||
field.state.value === priority
|
{(["low", "normal", "high"] as const).map((priority) => (
|
||||||
? styles.reportIssuePriorityButtonSelected
|
<OptionButton
|
||||||
: ''
|
key={priority}
|
||||||
}`}
|
onClick={() => field.handleChange(priority)}
|
||||||
type="button"
|
selected={field.state.value === priority}
|
||||||
onClick={() => field.handleChange(priority)}
|
>
|
||||||
>
|
{priority[0].toUpperCase()}
|
||||||
{priority[0].toUpperCase()}
|
{priority.slice(1)}
|
||||||
{priority.slice(1)}
|
</OptionButton>
|
||||||
</button>
|
))}
|
||||||
))}
|
</OptionButtonGroup>
|
||||||
</div>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
</form.Subscribe>
|
</form.Subscribe>
|
||||||
|
|
@ -316,12 +319,12 @@ function ReportIssueModal({
|
||||||
<form.Subscribe selector={(state) => state.errors}>
|
<form.Subscribe selector={(state) => state.errors}>
|
||||||
{(errors) => {
|
{(errors) => {
|
||||||
const error = getReportIssueFormError(errors);
|
const error = getReportIssueFormError(errors);
|
||||||
return error ? <div className={styles.reportIssueError}>{error}</div> : null;
|
return <FormError message={error} />;
|
||||||
}}
|
}}
|
||||||
</form.Subscribe>
|
</form.Subscribe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.modalFooter}>
|
<FormActions className={styles.modalFooter}>
|
||||||
<button
|
<button
|
||||||
className={`${styles.modalButton} ${styles.modalButtonSecondary}`}
|
className={`${styles.modalButton} ${styles.modalButtonSecondary}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -344,12 +347,12 @@ 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>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</form.Subscribe>
|
</form.Subscribe>
|
||||||
</div>
|
</FormActions>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -369,27 +372,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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -828,6 +828,14 @@
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issuesHeaderRight {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issuesFilters {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.issuesFilterSelect {
|
.issuesFilterSelect {
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue