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.
|
* Compose modal — rich text editor (TipTap) with template insert.
|
||||||
* Used for new mail, reply, and forward.
|
* Used for new mail, reply, and forward.
|
||||||
|
* Form validation: React Hook Form + Zod.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
@@ -31,6 +35,38 @@ export interface ComposeModalProps {
|
|||||||
onClose: () => void;
|
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({
|
export function ComposeModal({
|
||||||
open,
|
open,
|
||||||
mode,
|
mode,
|
||||||
@@ -46,46 +82,61 @@ export function ComposeModal({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const authUser = useAuthStore((s) => s.user);
|
const authUser = useAuthStore((s) => s.user);
|
||||||
const authTenant = useAuthStore((s) => s.currentTenant);
|
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 [showCc, setShowCc] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [savingDraft, setSavingDraft] = useState(false);
|
|
||||||
const [selectedSignatureId, setSelectedSignatureId] = useState('');
|
const [selectedSignatureId, setSelectedSignatureId] = useState('');
|
||||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||||
const [attachments, setAttachments] = useState<UploadedAttachment[]>([]);
|
const [attachments, setAttachments] = useState<UploadedAttachment[]>([]);
|
||||||
const [uploadingFile, setUploadingFile] = useState(false);
|
const [uploadingFile, setUploadingFile] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [savingDraft, setSavingDraft] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
if (mode === 'reply' && replyToMail) {
|
if (mode === 'reply' && replyToMail) {
|
||||||
setTo(replyToMail.from_address);
|
reset({
|
||||||
setSubject(replyToMail.subject.startsWith('Re: ') ? replyToMail.subject : `Re: ${replyToMail.subject}`);
|
to: replyToMail.from_address,
|
||||||
setBody(`\n\n---\n${replyToMail.body_text.slice(0, 200)}`);
|
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) {
|
} else if (mode === 'forward' && forwardMail) {
|
||||||
setTo('');
|
reset({
|
||||||
setSubject(forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`);
|
to: '',
|
||||||
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)}`);
|
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) {
|
} else if (mode === 'draft' && draftMail) {
|
||||||
setTo(draftMail.to_addresses.join(', '));
|
reset({
|
||||||
setCc(draftMail.cc_addresses.join(', '));
|
to: draftMail.to_addresses.join(', '),
|
||||||
setBcc(draftMail.bcc_addresses.join(', '));
|
cc: draftMail.cc_addresses.join(', '),
|
||||||
setSubject(draftMail.subject);
|
bcc: draftMail.bcc_addresses.join(', '),
|
||||||
setBody(draftMail.body_html || draftMail.body_text || '');
|
subject: draftMail.subject,
|
||||||
|
body: draftMail.body_html || draftMail.body_text || '',
|
||||||
|
});
|
||||||
setShowCc(draftMail.cc_addresses.length > 0 || draftMail.bcc_addresses.length > 0);
|
setShowCc(draftMail.cc_addresses.length > 0 || draftMail.bcc_addresses.length > 0);
|
||||||
} else {
|
} else {
|
||||||
setTo('');
|
reset({ to: '', cc: '', bcc: '', subject: '', body: '' });
|
||||||
setCc('');
|
|
||||||
setBcc('');
|
|
||||||
setSubject('');
|
|
||||||
setBody('');
|
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
}
|
}
|
||||||
}, [open, mode, replyToMail, forwardMail, draftMail, t]);
|
}, [open, mode, replyToMail, forwardMail, draftMail, t, reset]);
|
||||||
|
|
||||||
const insertSignature = useCallback((signatureId: string) => {
|
const insertSignature = useCallback((signatureId: string) => {
|
||||||
setSelectedSignatureId(signatureId);
|
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: 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 },
|
{ 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) => {
|
const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => {
|
||||||
setBody(templateBody);
|
setValue('body', templateBody);
|
||||||
if (templateSubject) {
|
if (templateSubject) {
|
||||||
setSubject(templateSubject);
|
setValue('subject', templateSubject);
|
||||||
}
|
}
|
||||||
setShowTemplatePicker(false);
|
setShowTemplatePicker(false);
|
||||||
}, []);
|
}, [setValue]);
|
||||||
|
|
||||||
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
@@ -143,40 +194,20 @@ export function ComposeModal({
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = useCallback(async () => {
|
const parseEmailList = (val: string): string[] =>
|
||||||
if (!onSaveDraft) return;
|
val ? val.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||||
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 handleSend = useCallback(async () => {
|
const onSendValidated = useCallback(async (data: ComposeFormData) => {
|
||||||
if (!to.trim()) return;
|
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
const toList = to.split(',').map((s) => s.trim()).filter(Boolean);
|
const toList = parseEmailList(data.to);
|
||||||
const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
const ccList = data.cc ? parseEmailList(data.cc) : undefined;
|
||||||
const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
const bccList = data.bcc ? parseEmailList(data.bcc) : undefined;
|
||||||
|
|
||||||
if (mode === 'reply' && replyToMail) {
|
if (mode === 'reply' && replyToMail) {
|
||||||
const replyPayload: ReplyPayload = {
|
const replyPayload: ReplyPayload = {
|
||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
body,
|
body: data.body,
|
||||||
is_html: true,
|
is_html: true,
|
||||||
to: toList,
|
to: toList,
|
||||||
cc: ccList,
|
cc: ccList,
|
||||||
@@ -187,7 +218,7 @@ export function ComposeModal({
|
|||||||
const fwdPayload: ForwardPayload = {
|
const fwdPayload: ForwardPayload = {
|
||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
to: toList,
|
to: toList,
|
||||||
body,
|
body: data.body,
|
||||||
is_html: true,
|
is_html: true,
|
||||||
signature_id: selectedSignatureId || null,
|
signature_id: selectedSignatureId || null,
|
||||||
};
|
};
|
||||||
@@ -198,8 +229,8 @@ export function ComposeModal({
|
|||||||
to: toList,
|
to: toList,
|
||||||
cc: ccList,
|
cc: ccList,
|
||||||
bcc: bccList,
|
bcc: bccList,
|
||||||
subject,
|
subject: data.subject,
|
||||||
body,
|
body: data.body,
|
||||||
is_html: true,
|
is_html: true,
|
||||||
signature_id: selectedSignatureId || null,
|
signature_id: selectedSignatureId || null,
|
||||||
attachments: attachments.map((a) => a.id),
|
attachments: attachments.map((a) => a.id),
|
||||||
@@ -211,13 +242,43 @@ export function ComposeModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
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 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 (
|
return (
|
||||||
<Modal open={open} onClose={onClose} title={title} size="xl" closeOnBackdrop={false} fullScreenMobile>
|
<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) */}
|
{/* Template picker toggle (formatting toolbar is in RichTextEditor) */}
|
||||||
<div className="flex items-center gap-2" data-testid="compose-toolbar">
|
<div className="flex items-center gap-2" data-testid="compose-toolbar">
|
||||||
<button
|
<button
|
||||||
@@ -240,8 +301,8 @@ export function ComposeModal({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input
|
<Input
|
||||||
label={t('mail.to')}
|
label={t('mail.to')}
|
||||||
value={to}
|
{...register('to')}
|
||||||
onChange={(e) => setTo(e.target.value)}
|
error={errorMsg(errors.to?.message)}
|
||||||
placeholder="recipient@example.com"
|
placeholder="recipient@example.com"
|
||||||
required
|
required
|
||||||
data-testid="compose-to"
|
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">
|
<div className="w-full grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
<Input
|
<Input
|
||||||
label={t('mail.cc')}
|
label={t('mail.cc')}
|
||||||
value={cc}
|
{...register('cc')}
|
||||||
onChange={(e) => setCc(e.target.value)}
|
error={errorMsg(errors.cc?.message)}
|
||||||
placeholder="cc@example.com"
|
placeholder="cc@example.com"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('mail.bcc')}
|
label={t('mail.bcc')}
|
||||||
value={bcc}
|
{...register('bcc')}
|
||||||
onChange={(e) => setBcc(e.target.value)}
|
error={errorMsg(errors.bcc?.message)}
|
||||||
placeholder="bcc@example.com"
|
placeholder="bcc@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,8 +335,8 @@ export function ComposeModal({
|
|||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
label={t('mail.subject')}
|
label={t('mail.subject')}
|
||||||
value={subject}
|
{...register('subject')}
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
error={errorMsg(errors.subject?.message)}
|
||||||
placeholder={t('mail.subjectPlaceholder')}
|
placeholder={t('mail.subjectPlaceholder')}
|
||||||
data-testid="compose-subject"
|
data-testid="compose-subject"
|
||||||
/>
|
/>
|
||||||
@@ -285,8 +346,8 @@ export function ComposeModal({
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.body')}</label>
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.body')}</label>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
content={body}
|
content={bodyValue}
|
||||||
onChange={setBody}
|
onChange={(html: string) => setValue('body', html)}
|
||||||
placeholder={t('mail.body')}
|
placeholder={t('mail.body')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -367,11 +428,11 @@ export function ComposeModal({
|
|||||||
{t('mail.saveDraft')}
|
{t('mail.saveDraft')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={handleSend} isLoading={sending} type="button" data-testid="compose-send">
|
<Button type="submit" isLoading={sending} data-testid="compose-send">
|
||||||
{t('mail.send')}
|
{t('mail.send')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user