Phase 6.1: ComposeModal on RHF + Zod
- Migrated ComposeModal from useState to React Hook Form + Zod - Zod schema: to (required, email list), cc/bcc (optional, email list), subject (required), body - Object-level superRefine for comma-separated email validation - Error display under each field via Input error prop - Preserved all existing functionality: attachments, signatures, templates, drafts - Added 4 validation tests (empty to, invalid email, empty subject, valid submit)
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('@/api/mail', () => ({
|
||||
decodeMimeHeader: (s: string) => s,
|
||||
fetchTemplates: vi.fn().mockResolvedValue([]),
|
||||
substituteTemplate: vi.fn().mockResolvedValue({ subject: '', body: '' }),
|
||||
uploadAttachment: vi.fn(),
|
||||
replaceSignatureVariables: (s: string) => s,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
useAuthStore: () => ({ user: null, currentTenant: null }),
|
||||
}));
|
||||
|
||||
import { ComposeModal } from '@/components/mail/ComposeModal';
|
||||
|
||||
function renderModal(props: Partial<React.ComponentProps<typeof ComposeModal>> = {}) {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
mode: 'new' as const,
|
||||
accountId: 'acc1',
|
||||
replyToMail: null,
|
||||
forwardMail: null,
|
||||
signatures: [],
|
||||
onSend: vi.fn().mockResolvedValue(undefined),
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
return render(<MemoryRouter><ComposeModal {...defaultProps} {...props} /></MemoryRouter>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ComposeModal validation (RHF + Zod)', () => {
|
||||
it('shows validation error when "to" is empty on submit', async () => {
|
||||
renderModal();
|
||||
fireEvent.click(screen.getByTestId('compose-send'));
|
||||
await waitFor(() => {
|
||||
const toInput = screen.getByTestId('compose-to');
|
||||
const errorEl = toInput.parentElement?.querySelector('.text-danger-500, .text-danger-600, .text-red');
|
||||
expect(errorEl).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for invalid email in "to" field', async () => {
|
||||
renderModal();
|
||||
fireEvent.change(screen.getByTestId('compose-to'), { target: { value: 'not-an-email' } });
|
||||
fireEvent.click(screen.getByTestId('compose-send'));
|
||||
await waitFor(() => {
|
||||
const toInput = screen.getByTestId('compose-to');
|
||||
const errorEl = toInput.parentElement?.querySelector('.text-danger-500, .text-danger-600, .text-red');
|
||||
expect(errorEl).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error when subject is empty', async () => {
|
||||
renderModal();
|
||||
fireEvent.change(screen.getByTestId('compose-to'), { target: { value: 'valid@example.com' } });
|
||||
fireEvent.click(screen.getByTestId('compose-send'));
|
||||
await waitFor(() => {
|
||||
const subjectInput = screen.getByTestId('compose-subject');
|
||||
const errorEl = subjectInput.parentElement?.querySelector('.text-danger-500, .text-danger-600, .text-red');
|
||||
expect(errorEl).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSend when form is valid', async () => {
|
||||
const onSend = vi.fn().mockResolvedValue(undefined);
|
||||
renderModal({ onSend });
|
||||
fireEvent.change(screen.getByTestId('compose-to'), { target: { value: 'valid@example.com' } });
|
||||
fireEvent.change(screen.getByTestId('compose-subject'), { target: { value: 'Test Subject' } });
|
||||
fireEvent.click(screen.getByTestId('compose-send'));
|
||||
await waitFor(() => {
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* Compose modal — rich text editor (TipTap) with template insert.
|
||||
* Used for new mail, reply, and forward.
|
||||
* Form validation: React Hook Form + Zod.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -31,6 +35,38 @@ export interface ComposeModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ── Zod Schema ──
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function validateEmailList(val: string, ctx: z.RefinementCtx, field: string, required: boolean) {
|
||||
const emails = val.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (required && emails.length === 0) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'required', path: [field] });
|
||||
return;
|
||||
}
|
||||
for (const email of emails) {
|
||||
if (!emailRegex.test(email)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'invalidEmail', path: [field] });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const composeSchema = z.object({
|
||||
to: z.string().default(''),
|
||||
cc: z.string().optional().default(''),
|
||||
bcc: z.string().optional().default(''),
|
||||
subject: z.string().min(1, 'required'),
|
||||
body: z.string().default(''),
|
||||
}).superRefine((data, ctx) => {
|
||||
validateEmailList(data.to, ctx, 'to', true);
|
||||
if (data.cc) validateEmailList(data.cc, ctx, 'cc', false);
|
||||
if (data.bcc) validateEmailList(data.bcc, ctx, 'bcc', false);
|
||||
});
|
||||
|
||||
type ComposeFormData = z.infer<typeof composeSchema>;
|
||||
|
||||
export function ComposeModal({
|
||||
open,
|
||||
mode,
|
||||
@@ -46,46 +82,61 @@ export function ComposeModal({
|
||||
const { t } = useTranslation();
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const authTenant = useAuthStore((s) => s.currentTenant);
|
||||
const [to, setTo] = useState('');
|
||||
const [cc, setCc] = useState('');
|
||||
const [bcc, setBcc] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [showCc, setShowCc] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [savingDraft, setSavingDraft] = useState(false);
|
||||
const [selectedSignatureId, setSelectedSignatureId] = useState('');
|
||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||
const [attachments, setAttachments] = useState<UploadedAttachment[]>([]);
|
||||
const [uploadingFile, setUploadingFile] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [savingDraft, setSavingDraft] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<ComposeFormData>({
|
||||
resolver: zodResolver(composeSchema),
|
||||
defaultValues: { to: '', cc: '', bcc: '', subject: '', body: '' },
|
||||
});
|
||||
|
||||
const bodyValue = watch('body');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === 'reply' && replyToMail) {
|
||||
setTo(replyToMail.from_address);
|
||||
setSubject(replyToMail.subject.startsWith('Re: ') ? replyToMail.subject : `Re: ${replyToMail.subject}`);
|
||||
setBody(`\n\n---\n${replyToMail.body_text.slice(0, 200)}`);
|
||||
reset({
|
||||
to: replyToMail.from_address,
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: replyToMail.subject.startsWith('Re: ') ? replyToMail.subject : `Re: ${replyToMail.subject}`,
|
||||
body: `\n\n---\n${replyToMail.body_text.slice(0, 200)}`,
|
||||
});
|
||||
} else if (mode === 'forward' && forwardMail) {
|
||||
setTo('');
|
||||
setSubject(forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`);
|
||||
setBody(`\n\n---\n${t('mail.forwarding')}\n${t('mail.from')}: ${forwardMail.from_address}\n${t('mail.subject')}: ${forwardMail.subject}\n\n${forwardMail.body_text.slice(0, 200)}`);
|
||||
reset({
|
||||
to: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`,
|
||||
body: `\n\n---\n${t('mail.forwarding')}\n${t('mail.from')}: ${forwardMail.from_address}\n${t('mail.subject')}: ${forwardMail.subject}\n\n${forwardMail.body_text.slice(0, 200)}`,
|
||||
});
|
||||
} else if (mode === 'draft' && draftMail) {
|
||||
setTo(draftMail.to_addresses.join(', '));
|
||||
setCc(draftMail.cc_addresses.join(', '));
|
||||
setBcc(draftMail.bcc_addresses.join(', '));
|
||||
setSubject(draftMail.subject);
|
||||
setBody(draftMail.body_html || draftMail.body_text || '');
|
||||
reset({
|
||||
to: draftMail.to_addresses.join(', '),
|
||||
cc: draftMail.cc_addresses.join(', '),
|
||||
bcc: draftMail.bcc_addresses.join(', '),
|
||||
subject: draftMail.subject,
|
||||
body: draftMail.body_html || draftMail.body_text || '',
|
||||
});
|
||||
setShowCc(draftMail.cc_addresses.length > 0 || draftMail.bcc_addresses.length > 0);
|
||||
} else {
|
||||
setTo('');
|
||||
setCc('');
|
||||
setBcc('');
|
||||
setSubject('');
|
||||
setBody('');
|
||||
reset({ to: '', cc: '', bcc: '', subject: '', body: '' });
|
||||
setAttachments([]);
|
||||
}
|
||||
}, [open, mode, replyToMail, forwardMail, draftMail, t]);
|
||||
}, [open, mode, replyToMail, forwardMail, draftMail, t, reset]);
|
||||
|
||||
const insertSignature = useCallback((signatureId: string) => {
|
||||
setSelectedSignatureId(signatureId);
|
||||
@@ -96,17 +147,17 @@ export function ComposeModal({
|
||||
{ name: authUser ? `${authUser.first_name} ${authUser.last_name}`.trim() : undefined, email: authUser?.email, role: authUser?.role, first_name: authUser?.first_name, last_name: authUser?.last_name },
|
||||
{ name: authTenant?.name },
|
||||
);
|
||||
setBody((prev) => `${prev}<br/><br/>---<br/>${processedHtml}`);
|
||||
setValue('body', `${bodyValue}<br/><br/>---<br/>${processedHtml}`);
|
||||
}
|
||||
}, [signatures, authUser, authTenant]);
|
||||
}, [signatures, authUser, authTenant, setValue, bodyValue]);
|
||||
|
||||
const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => {
|
||||
setBody(templateBody);
|
||||
setValue('body', templateBody);
|
||||
if (templateSubject) {
|
||||
setSubject(templateSubject);
|
||||
setValue('subject', templateSubject);
|
||||
}
|
||||
setShowTemplatePicker(false);
|
||||
}, []);
|
||||
}, [setValue]);
|
||||
|
||||
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
@@ -143,40 +194,20 @@ export function ComposeModal({
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
if (!onSaveDraft) return;
|
||||
setSavingDraft(true);
|
||||
try {
|
||||
const toList = to.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
const payload: MailDraftPayload = {
|
||||
account_id: accountId,
|
||||
to: toList,
|
||||
cc: ccList,
|
||||
bcc: bccList,
|
||||
subject,
|
||||
body_text: body.replace(/<[^>]*>/g, ''),
|
||||
body_html: body,
|
||||
};
|
||||
await onSaveDraft(payload);
|
||||
} finally {
|
||||
setSavingDraft(false);
|
||||
}
|
||||
}, [to, cc, bcc, subject, body, accountId, onSaveDraft]);
|
||||
const parseEmailList = (val: string): string[] =>
|
||||
val ? val.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!to.trim()) return;
|
||||
const onSendValidated = useCallback(async (data: ComposeFormData) => {
|
||||
setSending(true);
|
||||
try {
|
||||
const toList = to.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
const toList = parseEmailList(data.to);
|
||||
const ccList = data.cc ? parseEmailList(data.cc) : undefined;
|
||||
const bccList = data.bcc ? parseEmailList(data.bcc) : undefined;
|
||||
|
||||
if (mode === 'reply' && replyToMail) {
|
||||
const replyPayload: ReplyPayload = {
|
||||
account_id: accountId,
|
||||
body,
|
||||
body: data.body,
|
||||
is_html: true,
|
||||
to: toList,
|
||||
cc: ccList,
|
||||
@@ -187,7 +218,7 @@ export function ComposeModal({
|
||||
const fwdPayload: ForwardPayload = {
|
||||
account_id: accountId,
|
||||
to: toList,
|
||||
body,
|
||||
body: data.body,
|
||||
is_html: true,
|
||||
signature_id: selectedSignatureId || null,
|
||||
};
|
||||
@@ -198,8 +229,8 @@ export function ComposeModal({
|
||||
to: toList,
|
||||
cc: ccList,
|
||||
bcc: bccList,
|
||||
subject,
|
||||
body,
|
||||
subject: data.subject,
|
||||
body: data.body,
|
||||
is_html: true,
|
||||
signature_id: selectedSignatureId || null,
|
||||
attachments: attachments.map((a) => a.id),
|
||||
@@ -211,13 +242,43 @@ export function ComposeModal({
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]);
|
||||
}, [mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]);
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
if (!onSaveDraft) return;
|
||||
const data = watch();
|
||||
setSavingDraft(true);
|
||||
try {
|
||||
const toList = parseEmailList(data.to);
|
||||
const ccList = data.cc ? parseEmailList(data.cc) : [];
|
||||
const bccList = data.bcc ? parseEmailList(data.bcc) : [];
|
||||
const payload: MailDraftPayload = {
|
||||
account_id: accountId,
|
||||
to: toList,
|
||||
cc: ccList,
|
||||
bcc: bccList,
|
||||
subject: data.subject,
|
||||
body_text: data.body.replace(/<[^>]*>/g, ''),
|
||||
body_html: data.body,
|
||||
};
|
||||
await onSaveDraft(payload);
|
||||
} finally {
|
||||
setSavingDraft(false);
|
||||
}
|
||||
}, [onSaveDraft, watch, accountId]);
|
||||
|
||||
const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : mode === 'draft' ? t('mail.editDraft') : t('mail.compose');
|
||||
|
||||
const errorMsg = (key: string | undefined) => {
|
||||
if (!key) return undefined;
|
||||
if (key === 'required') return t('validation.required');
|
||||
if (key === 'invalidEmail') return t('validation.email');
|
||||
return key;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={title} size="xl" closeOnBackdrop={false} fullScreenMobile>
|
||||
<div className="space-y-4" data-testid="compose-modal">
|
||||
<form onSubmit={handleSubmit(onSendValidated)} className="space-y-4" data-testid="compose-modal">
|
||||
{/* Template picker toggle (formatting toolbar is in RichTextEditor) */}
|
||||
<div className="flex items-center gap-2" data-testid="compose-toolbar">
|
||||
<button
|
||||
@@ -240,8 +301,8 @@ export function ComposeModal({
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
label={t('mail.to')}
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
{...register('to')}
|
||||
error={errorMsg(errors.to?.message)}
|
||||
placeholder="recipient@example.com"
|
||||
required
|
||||
data-testid="compose-to"
|
||||
@@ -259,14 +320,14 @@ export function ComposeModal({
|
||||
<div className="w-full grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Input
|
||||
label={t('mail.cc')}
|
||||
value={cc}
|
||||
onChange={(e) => setCc(e.target.value)}
|
||||
{...register('cc')}
|
||||
error={errorMsg(errors.cc?.message)}
|
||||
placeholder="cc@example.com"
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.bcc')}
|
||||
value={bcc}
|
||||
onChange={(e) => setBcc(e.target.value)}
|
||||
{...register('bcc')}
|
||||
error={errorMsg(errors.bcc?.message)}
|
||||
placeholder="bcc@example.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -274,8 +335,8 @@ export function ComposeModal({
|
||||
</div>
|
||||
<Input
|
||||
label={t('mail.subject')}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
{...register('subject')}
|
||||
error={errorMsg(errors.subject?.message)}
|
||||
placeholder={t('mail.subjectPlaceholder')}
|
||||
data-testid="compose-subject"
|
||||
/>
|
||||
@@ -285,8 +346,8 @@ export function ComposeModal({
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.body')}</label>
|
||||
<RichTextEditor
|
||||
content={body}
|
||||
onChange={setBody}
|
||||
content={bodyValue}
|
||||
onChange={(html: string) => setValue('body', html)}
|
||||
placeholder={t('mail.body')}
|
||||
/>
|
||||
</div>
|
||||
@@ -367,11 +428,11 @@ export function ComposeModal({
|
||||
{t('mail.saveDraft')}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSend} isLoading={sending} type="button" data-testid="compose-send">
|
||||
<Button type="submit" isLoading={sending} data-testid="compose-send">
|
||||
{t('mail.send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user