Skip to content

Commit d02e009

Browse files
authored
Merge pull request #442 from bounswe/feat/430-report-system-frontend
Feat/430 report system frontend
2 parents 307a906 + f9b2ee8 commit d02e009

File tree

7 files changed

+545
-30
lines changed

7 files changed

+545
-30
lines changed
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { useState, useEffect, useCallback } from 'react';
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogDescription,
6+
DialogFooter,
7+
DialogHeader,
8+
DialogTitle,
9+
} from '@/components/ui/dialog';
10+
import { Button } from '@/components/ui/button';
11+
import { Textarea } from '@/components/ui/textarea';
12+
import { Label } from '@/components/ui/label';
13+
import { toast } from 'react-toastify';
14+
import { Loader2 } from 'lucide-react';
15+
16+
export const ReportReason = {
17+
FAKE: 'FAKE',
18+
SPAM: 'SPAM',
19+
OFFENSIVE: 'OFFENSIVE',
20+
OTHER: 'OTHER',
21+
} as const;
22+
23+
export type ReportReasonType = typeof ReportReason[keyof typeof ReportReason];
24+
25+
export interface ReportModalProps {
26+
open: boolean;
27+
onClose: () => void;
28+
29+
// Context info
30+
title: string;
31+
subtitle?: string;
32+
contextSnippet?: string;
33+
reportType?: string;
34+
reportedName?: string;
35+
36+
// Textarea configuration
37+
messageLabel?: string;
38+
messagePlaceholder?: string;
39+
initialMessage?: string;
40+
41+
// Submission logic provided by caller
42+
onSubmit: (message: string, reason: ReportReasonType) => Promise<void> | void;
43+
44+
// Optional loading indicator for lightweight open -> hydrate flow
45+
isLoading?: boolean;
46+
}
47+
48+
export function ReportModal({
49+
open,
50+
onClose,
51+
title,
52+
subtitle,
53+
contextSnippet,
54+
reportType,
55+
reportedName,
56+
messageLabel = 'Explain why you are reporting this',
57+
messagePlaceholder = 'Please provide details...',
58+
initialMessage = '',
59+
onSubmit,
60+
isLoading = false,
61+
}: ReportModalProps) {
62+
const [message, setMessage] = useState(initialMessage);
63+
const [reason, setReason] = useState<ReportReasonType>(ReportReason.OTHER);
64+
const [isSubmitting, setIsSubmitting] = useState(false);
65+
const [error, setError] = useState<string | null>(null);
66+
67+
// Reset state when modal opens
68+
useEffect(() => {
69+
if (open) {
70+
setMessage(initialMessage);
71+
setReason(ReportReason.OTHER);
72+
setError(null);
73+
setIsSubmitting(false);
74+
}
75+
}, [open, initialMessage]);
76+
77+
const handleSubmit = useCallback(async () => {
78+
if (!message.trim()) {
79+
setError('Please enter a message explaining the report.');
80+
return;
81+
}
82+
83+
setIsSubmitting(true);
84+
setError(null);
85+
86+
try {
87+
await onSubmit(message, reason);
88+
toast.success('Thank you, your report has been submitted.');
89+
onClose();
90+
setMessage('');
91+
setReason(ReportReason.OTHER);
92+
} catch (err) {
93+
console.error('Report submission failed:', err);
94+
setError('Failed to submit report. Please try again.');
95+
} finally {
96+
setIsSubmitting(false);
97+
}
98+
}, [message, reason, onSubmit, onClose]);
99+
100+
return (
101+
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
102+
<DialogContent className="sm:max-w-md">
103+
<DialogHeader>
104+
<DialogTitle>{title}</DialogTitle>
105+
{subtitle && <DialogDescription>{subtitle}</DialogDescription>}
106+
</DialogHeader>
107+
108+
<div className="flex flex-col gap-4 py-2">
109+
{isLoading ? (
110+
<div className="flex items-center justify-center py-6 text-muted-foreground">
111+
<Loader2 className="h-5 w-5 animate-spin mr-2" />
112+
<span>Loading…</span>
113+
</div>
114+
) : (
115+
<>
116+
{(reportType || reportedName) && (
117+
<div className="flex flex-col gap-1 text-sm">
118+
{reportType && (
119+
<div className="flex items-center gap-2">
120+
<span className="font-semibold text-muted-foreground">Type:</span>
121+
<span className="px-2 py-0.5 bg-secondary rounded-md text-secondary-foreground text-xs font-medium uppercase">
122+
{reportType}
123+
</span>
124+
</div>
125+
)}
126+
{reportedName && (
127+
<div className="flex items-center gap-2">
128+
<span className="font-semibold text-muted-foreground">Author:</span>
129+
<span className="font-medium">{reportedName}</span>
130+
</div>
131+
)}
132+
</div>
133+
)}
134+
135+
{contextSnippet && (
136+
<div className="bg-muted/50 rounded-md p-3 text-sm italic text-muted-foreground border">
137+
"{contextSnippet.length > 200 ? contextSnippet.slice(0, 200) + '...' : contextSnippet}"
138+
</div>
139+
)}
140+
141+
<div className="grid gap-2">
142+
<Label htmlFor="report-reason">Reason</Label>
143+
<select
144+
id="report-reason"
145+
value={reason}
146+
onChange={(e) => setReason(e.target.value as ReportReasonType)}
147+
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
148+
>
149+
<option value={ReportReason.FAKE}>Fake</option>
150+
<option value={ReportReason.SPAM}>Spam</option>
151+
<option value={ReportReason.OFFENSIVE}>Offensive</option>
152+
<option value={ReportReason.OTHER}>Other</option>
153+
</select>
154+
</div>
155+
156+
<div className="grid gap-2">
157+
<Label htmlFor="report-message">{messageLabel}</Label>
158+
<Textarea
159+
id="report-message"
160+
placeholder={messagePlaceholder}
161+
value={message}
162+
onChange={(e) => {
163+
setMessage(e.target.value);
164+
if (error) setError(null);
165+
}}
166+
className={error ? 'border-red-500 focus-visible:ring-red-500' : ''}
167+
rows={4}
168+
/>
169+
{error && <p className="text-sm text-red-500">{error}</p>}
170+
</div>
171+
</>
172+
)}
173+
</div>
174+
175+
<DialogFooter className="sm:justify-end">
176+
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
177+
Cancel
178+
</Button>
179+
<Button onClick={handleSubmit} disabled={isSubmitting || isLoading || !message.trim()}>
180+
{isSubmitting ? 'Submitting...' : 'Submit Report'}
181+
</Button>
182+
</DialogFooter>
183+
</DialogContent>
184+
</Dialog>
185+
);
186+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { ReportModal } from '../ReportModal';
4+
import userEvent from '@testing-library/user-event';
5+
6+
// Mock react-toastify
7+
vi.mock('react-toastify', () => ({
8+
toast: {
9+
success: vi.fn(),
10+
error: vi.fn(),
11+
},
12+
}));
13+
14+
describe('ReportModal', () => {
15+
const defaultProps = {
16+
open: true,
17+
onClose: vi.fn(),
18+
title: 'Report Item',
19+
onSubmit: vi.fn(),
20+
};
21+
22+
it('renders correctly with required props', () => {
23+
render(<ReportModal {...defaultProps} />);
24+
expect(screen.getByText('Report Item')).toBeInTheDocument();
25+
expect(screen.getByRole('button', { name: /submit report/i })).toBeInTheDocument();
26+
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
27+
});
28+
29+
it('renders subtitle and context snippet when provided', () => {
30+
render(
31+
<ReportModal
32+
{...defaultProps}
33+
subtitle="Test Subtitle"
34+
contextSnippet="This is a snippet"
35+
/>
36+
);
37+
expect(screen.getByText('Test Subtitle')).toBeInTheDocument();
38+
expect(screen.getByText(/"This is a snippet"/)).toBeInTheDocument();
39+
});
40+
41+
it('renders reportType and reportedName when provided', () => {
42+
render(
43+
<ReportModal
44+
{...defaultProps}
45+
reportType="Review"
46+
reportedName="John Doe"
47+
/>
48+
);
49+
expect(screen.getByText('Review')).toBeInTheDocument();
50+
expect(screen.getByText('John Doe')).toBeInTheDocument();
51+
});
52+
53+
it('validates empty message on submit', async () => {
54+
render(<ReportModal {...defaultProps} />);
55+
const submitButton = screen.getByRole('button', { name: /submit report/i });
56+
57+
// Initial state: button is disabled because message is empty (if we implemented that)
58+
// The component implementation disables the button if !message.trim()
59+
expect(submitButton).toBeDisabled();
60+
61+
// If we want to test the error message, we might need to enable it first or check logic
62+
// But since it's disabled, we can't click it to trigger validation error unless we type spaces
63+
// Let's try typing spaces
64+
/*
65+
const textarea = screen.getByRole('textbox');
66+
await userEvent.type(textarea, ' ');
67+
expect(submitButton).toBeDisabled(); // Should still be disabled
68+
*/
69+
});
70+
71+
it('calls onSubmit with message when valid', async () => {
72+
const onSubmit = vi.fn();
73+
render(<ReportModal {...defaultProps} onSubmit={onSubmit} />);
74+
75+
const textarea = screen.getByRole('textbox');
76+
await userEvent.type(textarea, 'This is a report reason');
77+
78+
const submitButton = screen.getByRole('button', { name: /submit report/i });
79+
expect(submitButton).toBeEnabled();
80+
81+
await userEvent.click(submitButton);
82+
83+
expect(onSubmit).toHaveBeenCalledWith('This is a report reason', 'OTHER');
84+
});
85+
86+
it('calls onSubmit with selected reason', async () => {
87+
const onSubmit = vi.fn();
88+
render(<ReportModal {...defaultProps} onSubmit={onSubmit} />);
89+
90+
const textarea = screen.getByRole('textbox');
91+
await userEvent.type(textarea, 'Spam content');
92+
93+
const select = screen.getByLabelText('Reason');
94+
await userEvent.selectOptions(select, 'SPAM');
95+
96+
const submitButton = screen.getByRole('button', { name: /submit report/i });
97+
await userEvent.click(submitButton);
98+
99+
expect(onSubmit).toHaveBeenCalledWith('Spam content', 'SPAM');
100+
});
101+
102+
it('handles submission error', async () => {
103+
const onSubmit = vi.fn().mockRejectedValue(new Error('Failed'));
104+
render(<ReportModal {...defaultProps} onSubmit={onSubmit} />);
105+
106+
const textarea = screen.getByRole('textbox');
107+
await userEvent.type(textarea, 'Reason');
108+
109+
const submitButton = screen.getByRole('button', { name: /submit report/i });
110+
await userEvent.click(submitButton);
111+
112+
await waitFor(() => {
113+
expect(screen.getByText('Failed to submit report. Please try again.')).toBeInTheDocument();
114+
});
115+
});
116+
117+
it('closes modal on cancel', async () => {
118+
const onClose = vi.fn();
119+
render(<ReportModal {...defaultProps} onClose={onClose} />);
120+
121+
const cancelButton = screen.getByRole('button', { name: /cancel/i });
122+
await userEvent.click(cancelButton);
123+
124+
expect(onClose).toHaveBeenCalled();
125+
});
126+
});

0 commit comments

Comments
 (0)