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:
Agent Zero
2026-07-24 00:21:09 +02:00
parent 888e7fee3e
commit d4aa661164
2 changed files with 220 additions and 74 deletions
@@ -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);
});
});
});
+134 -73
View File
@@ -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>
);
}