feat: add Edit contact button to email viewer contact sidebar
Clicking an email address in the viewer already shows a contact detail sidebar. An "Edit" button now appears there (for known contacts) that navigates directly to the contact edit form via the existing URL-param intent system (?contactId=…&view=edit), removing the need to open the Contacts page manually and search for the contact.
This commit is contained in:
committed by
Linus Rath
parent
798a33495e
commit
aee4bd78db
@@ -0,0 +1,106 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ContactSidebarPanel } from '../email-viewer';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
const contact: ContactCard = {
|
||||
id: 'c1',
|
||||
addressBookIds: {},
|
||||
name: {
|
||||
components: [
|
||||
{ kind: 'given', value: 'Alice' },
|
||||
{ kind: 'surname', value: 'Smith' },
|
||||
],
|
||||
isOrdered: true,
|
||||
},
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
};
|
||||
|
||||
const unknownEmail = 'unknown@example.com';
|
||||
|
||||
describe('ContactSidebarPanel', () => {
|
||||
beforeEach(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Edit button when contact is known and onEditContact is provided', () => {
|
||||
render(
|
||||
<ContactSidebarPanel
|
||||
email="alice@example.com"
|
||||
contact={contact}
|
||||
onClose={vi.fn()}
|
||||
onEditContact={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// useTranslations mock returns the key, so we look for the common.edit key
|
||||
expect(screen.getByTitle('contact_sidebar.action_edit_title')).toBeInTheDocument();
|
||||
expect(screen.getByText('edit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onEditContact when Edit button is clicked', () => {
|
||||
const onEditContact = vi.fn();
|
||||
render(
|
||||
<ContactSidebarPanel
|
||||
email="alice@example.com"
|
||||
contact={contact}
|
||||
onClose={vi.fn()}
|
||||
onEditContact={onEditContact}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTitle('contact_sidebar.action_edit_title'));
|
||||
expect(onEditContact).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not show Edit button when contact is null', () => {
|
||||
render(
|
||||
<ContactSidebarPanel
|
||||
email={unknownEmail}
|
||||
contact={null}
|
||||
onClose={vi.fn()}
|
||||
onEditContact={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show Edit button when onEditContact is not provided', () => {
|
||||
render(
|
||||
<ContactSidebarPanel
|
||||
email="alice@example.com"
|
||||
contact={contact}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "not in contacts" message and Add button for unknown email', () => {
|
||||
const onAddToContacts = vi.fn();
|
||||
render(
|
||||
<ContactSidebarPanel
|
||||
email={unknownEmail}
|
||||
contact={null}
|
||||
onClose={vi.fn()}
|
||||
onAddToContacts={onAddToContacts}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('contact_sidebar.not_in_contacts')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('contact_sidebar.add_to_contacts'));
|
||||
expect(onAddToContacts).toHaveBeenCalledWith(unknownEmail, undefined);
|
||||
});
|
||||
|
||||
it('calls onClose when the close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ContactSidebarPanel
|
||||
email="alice@example.com"
|
||||
contact={contact}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('contact_sidebar.close'));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -609,19 +609,23 @@ function renderClickableRecipients(
|
||||
}
|
||||
|
||||
// Contact sidebar panel that slides in from the right on desktop
|
||||
function ContactSidebarPanel({
|
||||
export function ContactSidebarPanel({
|
||||
email,
|
||||
contact,
|
||||
senderName,
|
||||
onClose,
|
||||
onAddToContacts,
|
||||
onEditContact,
|
||||
}: {
|
||||
email: string;
|
||||
contact: ContactCard | null;
|
||||
senderName?: string;
|
||||
onClose: () => void;
|
||||
onAddToContacts?: (email: string, name?: string) => void;
|
||||
onEditContact?: () => void;
|
||||
}) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tCommon = useTranslations('common');
|
||||
const name = contact ? getContactDisplayName(contact) : senderName || null;
|
||||
const primaryEmail = contact ? getContactPrimaryEmail(contact) : email;
|
||||
const emails = contact?.emails ? Object.values(contact.emails) : [];
|
||||
@@ -633,9 +637,9 @@ function ContactSidebarPanel({
|
||||
const handleCopy = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success("Copied!");
|
||||
toast.success(t('contact_sidebar.copied'));
|
||||
} catch {
|
||||
toast.error("Failed to copy");
|
||||
toast.error(t('contact_sidebar.copy_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -643,11 +647,11 @@ function ContactSidebarPanel({
|
||||
<div className="w-[320px] shrink-0 border-l border-border bg-background flex flex-col h-full animate-in slide-in-from-right-5 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground truncate">Contact</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground truncate">{t('contact_sidebar.title')}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label="Close sidebar"
|
||||
aria-label={t('contact_sidebar.close')}
|
||||
>
|
||||
<PanelRightClose className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -685,19 +689,29 @@ function ContactSidebarPanel({
|
||||
<a
|
||||
href={`mailto:${primaryEmail}`}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
|
||||
title="Send email"
|
||||
title={t('contact_sidebar.action_email_title')}
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
Email
|
||||
{t('contact_sidebar.action_email')}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleCopy(primaryEmail)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
|
||||
title="Copy email"
|
||||
title={t('contact_sidebar.action_copy_title')}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
Copy
|
||||
{t('contact_sidebar.action_copy')}
|
||||
</button>
|
||||
{contact && onEditContact && (
|
||||
<button
|
||||
onClick={onEditContact}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
|
||||
title={t('contact_sidebar.action_edit_title')}
|
||||
>
|
||||
<EditIcon className="w-3.5 h-3.5" />
|
||||
{tCommon('edit')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details sections */}
|
||||
@@ -705,7 +719,7 @@ function ContactSidebarPanel({
|
||||
<div className="px-4 pb-4 space-y-4">
|
||||
{/* Emails */}
|
||||
{emails.length > 0 && (
|
||||
<SidebarSection icon={Mail} title="Emails">
|
||||
<SidebarSection icon={Mail} title={t('contact_sidebar.section_emails')}>
|
||||
{emails.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-2 group">
|
||||
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline truncate">
|
||||
@@ -725,7 +739,7 @@ function ContactSidebarPanel({
|
||||
|
||||
{/* Phones */}
|
||||
{phones.length > 0 && (
|
||||
<SidebarSection icon={Phone} title="Phones">
|
||||
<SidebarSection icon={Phone} title={t('contact_sidebar.section_phones')}>
|
||||
{phones.map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-2 group">
|
||||
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
|
||||
@@ -745,7 +759,7 @@ function ContactSidebarPanel({
|
||||
|
||||
{/* Organizations */}
|
||||
{orgs.length > 1 && (
|
||||
<SidebarSection icon={Building} title="Organizations">
|
||||
<SidebarSection icon={Building} title={t('contact_sidebar.section_organizations')}>
|
||||
{orgs.map((o, i) => (
|
||||
<div key={i} className="text-sm">
|
||||
{o.name}
|
||||
@@ -759,7 +773,7 @@ function ContactSidebarPanel({
|
||||
|
||||
{/* Addresses */}
|
||||
{addresses.length > 0 && (
|
||||
<SidebarSection icon={MapPin} title="Addresses">
|
||||
<SidebarSection icon={MapPin} title={t('contact_sidebar.section_addresses')}>
|
||||
{addresses.map((a, i) => (
|
||||
<div key={i} className="text-sm text-muted-foreground">
|
||||
{a.full || a.fullAddress
|
||||
@@ -774,7 +788,7 @@ function ContactSidebarPanel({
|
||||
|
||||
{/* Notes */}
|
||||
{notes.length > 0 && (
|
||||
<SidebarSection icon={StickyNote} title="Notes">
|
||||
<SidebarSection icon={StickyNote} title={t('contact_sidebar.section_notes')}>
|
||||
{notes.map((n, i) => (
|
||||
<p key={i} className="text-sm text-muted-foreground whitespace-pre-wrap">{n.note}</p>
|
||||
))}
|
||||
@@ -787,7 +801,7 @@ function ContactSidebarPanel({
|
||||
{!contact && (
|
||||
<div className="px-4 pb-4 text-center space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Not in your contacts
|
||||
{t('contact_sidebar.not_in_contacts')}
|
||||
</p>
|
||||
{onAddToContacts && (
|
||||
<button
|
||||
@@ -795,7 +809,7 @@ function ContactSidebarPanel({
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:text-primary/80 px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
Add to contacts
|
||||
{t('contact_sidebar.add_to_contacts')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -5840,6 +5854,10 @@ export function EmailViewer({
|
||||
return allRecipients.find(r => r.email.toLowerCase() === contactSidebarEmail.toLowerCase())?.name;
|
||||
})()}
|
||||
onClose={() => setContactSidebarEmail(null)}
|
||||
onEditContact={sidebarContact ? () => {
|
||||
router.push(`/contacts?contactId=${sidebarContact.id}&view=edit`);
|
||||
setContactSidebarEmail(null);
|
||||
} : undefined}
|
||||
onAddToContacts={(addr, name) => {
|
||||
const { createContact, addLocalContact, supportsSync } = useContactStore.getState();
|
||||
const client = useAuthStore.getState().client;
|
||||
|
||||
Reference in New Issue
Block a user