Files
leocrm/frontend/src/components/mail/RuleEditor.tsx
T
Agent Zero 761f8d88dc Phase 6.6: Mail-Settings-Forms on RHF + Zod
- MailSettings account form: RHF+Zod (email valid, password required, imap/smtp host required, ports numeric)
- SignatureManager: RHF+Zod (name required, body_html, is_default)
- RuleEditor: RHF+Zod (name required, priority numeric)
- LabelManager: RHF+Zod (name required, color optional)
- VacationResponder: RHF+Zod (enabled, dates with end>start validation, subject, body)
- Error display under each field
- Preserved all existing functionality: CRUD, templates, variables
- Added 2 validation tests for MailSettings account form
2026-07-24 00:38:40 +02:00

281 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Rule editor — condition builder + action selector for mail rules.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { useToast } from '@/components/ui/Toast';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { Loader2 } from 'lucide-react';
import {
fetchRules,
createRule,
deleteRule,
type MailRule,
type RuleCondition,
type RuleAction,
type RuleConditionType,
type RuleActionType,
} from '@/api/mail';
const conditionTypeOptions = (t: (s: string) => string) => [
{ value: 'from_contains', label: t('mail.ruleCondFromContains') },
{ value: 'to_contains', label: t('mail.ruleCondToContains') },
{ value: 'subject_contains', label: t('mail.ruleCondSubjectContains') },
{ value: 'body_contains', label: t('mail.ruleCondBodyContains') },
{ value: 'has_attachment', label: t('mail.ruleCondHasAttachment') },
];
const actionTypeOptions = (t: (s: string) => string) => [
{ value: 'move_to_folder', label: t('mail.ruleActionMoveToFolder') },
{ value: 'mark_as_read', label: t('mail.ruleActionMarkAsRead') },
{ value: 'mark_as_flagged', label: t('mail.ruleActionMarkAsFlagged') },
{ value: 'forward_to', label: t('mail.ruleActionForwardTo') },
{ value: 'delete', label: t('mail.ruleActionDelete') },
];
export function RuleEditor({ accountId }: { accountId: string }) {
const { t } = useTranslation();
const toast = useToast();
const [rules, setRules] = useState<MailRule[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [conditions, setConditions] = useState<RuleCondition[]>([{ type: 'from_contains', value: '' }]);
const [actions, setActions] = useState<RuleAction[]>([{ type: 'mark_as_read', value: '' }]);
const [saving, setSaving] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<MailRule | null>(null);
// ── Rule form (RHF + Zod) ──
const ruleSchema = z.object({
name: z.string().min(1, 'required'),
priority: z.coerce.number().int().min(1, 'invalidNumber').default(1),
});
type RuleFormData = z.infer<typeof ruleSchema>;
const { register: registerRule, handleSubmit: handleSubmitRule, reset: resetRule, formState: { errors: ruleErrors } } = useForm<RuleFormData>({
resolver: zodResolver(ruleSchema),
defaultValues: { name: '', priority: 1 },
});
const load = useCallback(() => {
setLoading(true);
fetchRules()
.then((data) => {
setRules(data);
setError(null);
})
.catch((err) => {
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const addCondition = () => {
setConditions((prev) => [...prev, { type: 'from_contains', value: '' }]);
};
const updateCondition = (index: number, field: 'type' | 'value', val: string) => {
setConditions((prev) => prev.map((c, i) => (i === index ? { ...c, [field]: val } : c)));
};
const removeCondition = (index: number) => {
setConditions((prev) => prev.filter((_, i) => i !== index));
};
const addAction = () => {
setActions((prev) => [...prev, { type: 'mark_as_read', value: '' }]);
};
const updateAction = (index: number, field: 'type' | 'value', val: string) => {
setActions((prev) => prev.map((a, i) => (i === index ? { ...a, [field]: val } : a)));
};
const removeAction = (index: number) => {
setActions((prev) => prev.filter((_, i) => i !== index));
};
const handleSave = useCallback(async (data: RuleFormData) => {
setSaving(true);
try {
const rule = await createRule({
name: data.name,
account_id: accountId,
priority: data.priority,
is_active: true,
conditions,
actions,
});
setRules((prev) => [...prev, rule].sort((a, b) => a.priority - b.priority));
toast.success(t('mail.ruleCreated'));
setShowForm(false);
resetRule({ name: '', priority: 1 });
setConditions([{ type: 'from_contains', value: '' }]);
setActions([{ type: 'mark_as_read', value: '' }]);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}, [accountId, conditions, actions, toast, t, resetRule]);
const handleDelete = useCallback(async () => {
if (!deleteTarget) return;
try {
await deleteRule(deleteTarget.id);
setRules((prev) => prev.filter((r) => r.id !== deleteTarget.id));
toast.success(t('mail.ruleDeleted'));
setDeleteTarget(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}, [deleteTarget, toast, t]);
if (loading) {
return (
<div className="flex items-center justify-center py-8" data-testid="rule-editor-loading">
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
</div>
);
}
if (error) {
return (
<div className="p-4 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="rule-editor-error">
<p className="text-sm text-danger-700">{error}</p>
</div>
);
}
return (
<div data-testid="rule-editor">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-secondary-900">{t('mail.rules')}</h3>
<Button size="sm" onClick={() => setShowForm(!showForm)} data-testid="new-rule-btn">{t('mail.newRule')}</Button>
</div>
{showForm && (
<Card className="mb-4" data-testid="rule-form">
<form onSubmit={handleSubmitRule(handleSave)} className="space-y-4">
<Input
label={t('mail.ruleName')}
{...registerRule('name')}
error={ruleErrors.name?.message === 'required' ? t('validation.required') : undefined}
placeholder={t('mail.ruleName')}
required
/>
<Input
label={t('mail.rulePriority')}
type="number"
{...registerRule('priority')}
error={ruleErrors.priority?.message === 'invalidNumber' ? t('validation.invalidNumber', 'Invalid number') : undefined}
/>
{/* Conditions */}
<div>
<p className="text-sm font-medium text-secondary-700 mb-2">{t('mail.ruleConditions')}</p>
<div className="space-y-2" data-testid="rule-conditions">
{conditions.map((cond, idx) => (
<div key={idx} className="flex items-center gap-2">
<Select
value={cond.type}
onChange={(e) => updateCondition(idx, 'type', e.target.value as RuleConditionType)}
options={conditionTypeOptions(t)}
className="flex-1"
/>
{cond.type !== 'has_attachment' && (
<Input
value={cond.value}
onChange={(e) => updateCondition(idx, 'value', e.target.value)}
placeholder={t('mail.ruleCondValue')}
className="flex-1"
/>
)}
<Button variant="ghost" size="sm" onClick={() => removeCondition(idx)} type="button"></Button>
</div>
))}
</div>
<Button variant="secondary" size="sm" onClick={addCondition} className="mt-2" type="button">{t('mail.addCondition')}</Button>
</div>
{/* Actions */}
<div>
<p className="text-sm font-medium text-secondary-700 mb-2">{t('mail.ruleActions')}</p>
<div className="space-y-2" data-testid="rule-actions">
{actions.map((act, idx) => (
<div key={idx} className="flex items-center gap-2">
<Select
value={act.type}
onChange={(e) => updateAction(idx, 'type', e.target.value as RuleActionType)}
options={actionTypeOptions(t)}
className="flex-1"
/>
{(act.type === 'move_to_folder' || act.type === 'forward_to') && (
<Input
value={act.value}
onChange={(e) => updateAction(idx, 'value', e.target.value)}
placeholder={t('mail.ruleActionValue')}
className="flex-1"
/>
)}
<Button variant="ghost" size="sm" onClick={() => removeAction(idx)} type="button"></Button>
</div>
))}
</div>
<Button variant="secondary" size="sm" onClick={addAction} className="mt-2" type="button">{t('mail.addAction')}</Button>
</div>
<div className="flex gap-2">
<Button type="submit" isLoading={saving} size="sm">{t('common.save')}</Button>
<Button variant="secondary" size="sm" type="button" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
</div>
</form>
</Card>
)}
{rules.length === 0 && !showForm ? (
<EmptyState title={t('mail.noRules')} description={t('mail.noRulesDesc')} />
) : (
<div className="space-y-2" data-testid="rule-list">
{rules.map((rule) => (
<Card key={rule.id}>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-xs text-secondary-400">#{rule.priority}</span>
<p className="font-medium text-secondary-900">{rule.name}</p>
</div>
<p className="text-sm text-secondary-500 mt-1">
{rule.conditions.length} {t('mail.ruleConditions')} {rule.actions.length} {t('mail.ruleActions')}
</p>
</div>
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(rule)} data-testid={`delete-rule-${rule.id}`}>{t('common.delete')}</Button>
</div>
</Card>
))}
</div>
)}
<ConfirmDialog
open={!!deleteTarget}
title={t('mail.deleteRule')}
message={t('mail.confirmDeleteRule')}
confirmLabel={t('common.delete')}
variant="danger"
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}