feat: allow contact cards for organizations #701
This commit is contained in:
@@ -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")} />
|
||||
|
||||
Reference in New Issue
Block a user