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
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Template picker dropdown for compose modal.
|
||||
* Lists available templates and inserts selected template body into editor.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fetchTemplates, substituteTemplate, type MailTemplate } from '@/api/mail';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
|
||||
export interface TemplatePickerProps {
|
||||
onSelect: (body: string, subject: string) => void;
|
||||
}
|
||||
|
||||
export function TemplatePicker({ onSelect }: TemplatePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchTemplates()
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setTemplates(data);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const handleSelect = (template: MailTemplate) => {
|
||||
substituteTemplate({
|
||||
template_id: template.id,
|
||||
variables: {},
|
||||
})
|
||||
.then((result) => {
|
||||
onSelect(result.body, result.subject || template.subject);
|
||||
})
|
||||
.catch(() => {
|
||||
onSelect(template.body, template.subject);
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4" data-testid="template-picker-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>
|
||||
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="template-picker-error">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (templates.length === 0) {
|
||||
return (
|
||||
<EmptyState title={t('mail.noTemplates')} description={t('mail.noTemplatesDesc')} data-testid="template-picker-empty" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-secondary-200 rounded-md p-2 bg-secondary-50" data-testid="template-picker">
|
||||
<p className="text-sm font-medium text-secondary-700 mb-2">{t('mail.selectTemplate')}</p>
|
||||
<ul className="space-y-1" role="list">
|
||||
{templates.map((template) => (
|
||||
<li key={template.id}>
|
||||
<button
|
||||
onClick={() => handleSelect(template)}
|
||||
className="w-full text-left px-3 py-2 rounded-md hover:bg-white text-sm min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
data-testid={`template-option-${template.id}`}
|
||||
>
|
||||
<span className="font-medium text-secondary-800">{template.name}</span>
|
||||
{template.variables.length > 0 && (
|
||||
<span className="ml-2 text-xs text-secondary-400">({template.variables.join(', ')})</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user