Files
leocrm/frontend/src/components/mail/RuleEditor.tsx
T
leocrm-bot 0070fb3aea T08c: Frontend Mail UI + Global Search UI — 44 tests, tsc clean, vite build pass
- Mail page: 3-pane layout (folder tree + mail list + reading pane)
- Compose modal: rich text editor (bold/italic/link), template picker, reply/forward pre-fill
- Mail settings: accounts, signatures, rules, labels, vacation, PGP (6 tabs)
- Shared mailbox selector: switch between personal + shared accounts
- Mail search bar + attachment download + create-event-from-mail
- Global search: tabs for companies/contacts/mails/files/events
- Search autocomplete in TopBar (existing SearchDropdown)
- API client: mail.ts (all endpoints)
- Routes: /mail, /mail/settings
- i18n: de.json + en.json mail + search translations
- 44 new tests (4 test files), full regression 318/318 pass
- tsc --noEmit: 0 errors, vite build: 267 modules
2026-07-01 20:43:49 +02:00

271 lines
10 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 { 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 {
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 [name, setName] = useState('');
const [priority, setPriority] = useState(1);
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);
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 () => {
if (!name.trim()) return;
setSaving(true);
try {
const rule = await createRule({
name,
account_id: accountId,
priority,
is_active: true,
conditions,
actions,
});
setRules((prev) => [...prev, rule].sort((a, b) => a.priority - b.priority));
toast.success(t('mail.ruleCreated'));
setShowForm(false);
setName('');
setPriority(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);
}
}, [name, accountId, priority, conditions, actions, toast, t]);
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">
<svg className="animate-spin h-5 w-5 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</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">
<div className="space-y-4">
<Input
label={t('mail.ruleName')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('mail.ruleName')}
required
/>
<Input
label={t('mail.rulePriority')}
type="number"
value={String(priority)}
onChange={(e) => setPriority(Number(e.target.value))}
/>
{/* 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 onClick={handleSave} isLoading={saving} size="sm">{t('common.save')}</Button>
<Button variant="secondary" size="sm" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
</div>
</div>
</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>
);
}