feat: allow contact cards for organizations #701

This commit is contained in:
Linus Rath
2026-07-30 19:44:36 +02:00
parent 348e032dce
commit e659fe3d38
29 changed files with 360 additions and 71 deletions
@@ -102,4 +102,98 @@ describe('ContactForm', () => {
expect.arrayContaining([expect.objectContaining({ kind: 'given', value: 'Jane' })])
);
});
it('saves an organization-only card when the organization type is selected', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('type_organization'));
fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } });
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
const savedData = onSave.mock.calls[0][0];
expect(savedData.kind).toBe('org');
expect(savedData.organizations.o0.name).toBe('Acme Corp');
// No personal name components; the org name carries the display name instead.
expect(savedData.name.components).toBeUndefined();
expect(savedData.name.full).toBe('Acme Corp');
});
it('hides the personal name fields in organization mode', () => {
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByPlaceholderText('given_name')).toBeInTheDocument();
fireEvent.click(screen.getByText('type_organization'));
expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('surname')).not.toBeInTheDocument();
// The organization field moves into the identity section, so it appears once.
expect(screen.getAllByPlaceholderText('organization_placeholder')).toHaveLength(1);
});
it('still requires a name in organization mode', async () => {
const onSave = vi.fn();
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('type_organization'));
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(screen.getByText('name_required')).toBeInTheDocument();
});
expect(onSave).not.toHaveBeenCalled();
});
it('accepts an organization instead of a personal name in person mode', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('section_work'));
fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } });
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
expect(onSave.mock.calls[0][0].name.full).toBe('Acme Corp');
});
it('opens an existing org card in organization mode', () => {
const orgContact: ContactCard = {
id: '2',
addressBookIds: {},
kind: 'org',
name: { full: 'Acme Corp' },
organizations: { o0: { name: 'Acme Corp' } },
};
render(<ContactForm contact={orgContact} onSave={vi.fn()} onCancel={vi.fn()} />);
expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument();
expect(screen.getByDisplayValue('Acme Corp')).toBeInTheDocument();
});
it('switches an org card back to a person', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const orgContact: ContactCard = {
id: '2',
addressBookIds: {},
kind: 'org',
name: { full: 'Acme Corp' },
organizations: { o0: { name: 'Acme Corp' } },
};
render(<ContactForm contact={orgContact} onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('type_person'));
fireEvent.change(screen.getByPlaceholderText('given_name'), { target: { value: 'Jane' } });
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
expect(onSave.mock.calls[0][0].kind).toBe('individual');
});
});
+3 -1
View File
@@ -171,7 +171,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
const hasNickname = nicknames.length > 0;
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
// On an organization card the org name is already the heading; don't repeat it.
const orgName = orgs[0]?.name;
const subtitleParts = [titleLine, orgName === name ? undefined : orgName].filter(Boolean) as string[];
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
const hasWork = titles.length > 0 || orgs.length > 0;
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
+117 -44
View File
@@ -266,6 +266,16 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : ""
);
// A card may describe an organization instead of a person (RFC 9553 kind "org").
// Older cards predate the explicit kind, so fall back to "has an org name but no
// personal name".
const [isOrg, setIsOrg] = useState(() => {
if (!contact) return false;
if (contact.kind) return contact.kind === "org";
const hasPersonName = !!(findComponent("given") || findComponent("surname"));
return !hasPersonName && !!Object.values(contact.organizations || {})[0]?.name;
});
const [jobTitle, setJobTitle] = useState(() => {
if (contact?.titles) {
const t = Object.values(contact.titles).find(t => t.kind !== "role");
@@ -424,7 +434,10 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
e.preventDefault();
setError(null);
if (!givenName.trim() && !surname.trim()) {
// An organization name identifies the card just as well as a personal name.
const orgName = organization.trim();
const hasPersonName = !!(givenName.trim() || surname.trim());
if (isOrg ? !orgName : (!hasPersonName && !orgName)) {
setError(t("name_required"));
return;
}
@@ -461,11 +474,19 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
const nameComponents = [];
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
if (!isOrg) {
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
}
// Without personal name components, carry the organization name in `name.full`
// so servers and other clients have something to display.
const nameValue: ContactCard["name"] = nameComponents.length > 0
? { components: nameComponents, isOrdered: true }
: { full: orgName };
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
@@ -530,14 +551,20 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const mediaValue: Record<string, ContactMedia> | null | undefined =
Object.keys(mediaMap).length > 0 ? mediaMap : (hadMedia ? null : undefined);
// Only send `kind` when this form owns the answer: switching a card between
// person and organization. Leave other kinds (group, location, ...) untouched.
const kindValue: ContactCard["kind"] | undefined =
isOrg ? "org" : (contact?.kind === "org" ? "individual" : undefined);
const data: Partial<ContactCard> = {
name: { components: nameComponents, isOrdered: true },
name: nameValue,
...(kindValue ? { kind: kindValue } : {}),
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined,
organizations: organization.trim()
? { o0: { name: organization.trim(), units: orgUnits } }
organizations: orgName
? { o0: { name: orgName, units: orgUnits } }
: undefined,
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined,
@@ -570,7 +597,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
}
};
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
const previewName = (isOrg ? "" : [givenName, surname].filter(Boolean).join(" ").trim()) || organization.trim();
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
return (
@@ -661,38 +688,81 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
)}
<FormSection icon={User} title={t("section_identity")}>
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("given_name")} <span className="text-red-500">*</span>
</label>
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("surname")} <span className="text-red-500">*</span>
</label>
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{t("contact_type")}</span>
<div role="radiogroup" aria-label={t("contact_type")} className="inline-flex gap-0.5 rounded-md border border-input p-0.5">
{[
{ org: false, label: t("type_person"), icon: User },
{ org: true, label: t("type_organization"), icon: Building },
].map(({ org, label, icon: Icon }) => (
<button
key={label}
type="button"
role="radio"
aria-checked={isOrg === org}
onClick={() => setIsOrg(org)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 text-xs rounded transition-colors",
isOrg === org
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
<Input value={additionalName} onChange={(e) => setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
{isOrg ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("organization")} <span className="text-red-500">*</span>
</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
) : (
<>
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("given_name")} <span className="text-red-500">*</span>
</label>
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("surname")} <span className="text-red-500">*</span>
</label>
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
<Input value={additionalName} onChange={(e) => setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
</>
)}
</FormSection>
{/* Email */}
@@ -806,12 +876,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</FormSection>
{/* Work & Organization */}
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!((organization && !isOrg) || department || jobTitle || role)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
</div>
{/* In organization mode the org name is the card's identity, edited above. */}
{!isOrg && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
</div>
)}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("department")}</label>
<Input value={department} onChange={(e) => setDepartment(e.target.value)} placeholder={t("department_placeholder")} />