contacts: switch a card between company and person
A company's name sat in N, but macOS writes it to ORG and FN;
the Company box now flips the kind and saves it the way that PUT had been.
A person's ORG, the place they work, gets a field and reads beneath their name;
a company's N is emptied, so no hidden name stays in the frame.
Assisted-by: Claude Opus 5.5 via Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/docs/plans/2026-10-07-switching-company-and-person.md b/docs/plans/2026-10-07-switching-company-and-person.md
new file mode 100644
index 0000000..32f810b
--- /dev/null
+++ b/docs/plans/2026-10-07-switching-company-and-person.md
@@ -0,0 +1,106 @@
+# Switching a card between company and person
+
+2026-10-07. The contact editor has a Company box, Contacts.app's own
+checkbox: ticked, the card is a company and its name is the one
+Organization field; unticked, a person and the First/Middle/Last pair.
+Switching moves the name to where macOS keeps it for that kind, which
+reverses one policy of `2026-09-24-company-cards.md`: a company's save
+now writes `ORG`.
+
+## What macOS does
+
+Recorded, not remembered: the fixture company card switched in
+Contacts.app against `rake dev`, two PUTs in `log/dev.log`.
+
+- **Company to person** (exchange `b5ce7eaebe0e`). `X-ABShowAs` is
+ dropped — a person is a card without the line, not one saying
+ `PERSON` — `ORG` stays as it was, and `N` and `FN` carry the names.
+ Unticking alone sent nothing: Contacts would not save a person
+ without a name until one was typed.
+- **Person to company** (exchange `ec5ce1d8dd03`, the names cleared by
+ hand in the same edit). `X-ABShowAs:COMPANY` comes back, `FN` is
+ `ORG`'s name, and `ORG` is unchanged.
+
+So macOS keeps a company's name in `ORG`, with `FN` following it. The
+editor kept it in `N`'s family slot and left `ORG` alone, which that
+plan already named as drift ("a renamed company can drift from the
+`ORG` it arrived with"); the recording shows it as a divergence from
+the client, so it goes.
+
+## The save
+
+`CardForm.contact_card` asks `CardForm.company?` which kind the save
+writes, and writes the name that kind keeps:
+
+- **A company** (`company_name`): the Organization field into `ORG`'s
+ first component, the units after it rejoined byte for byte as
+ `n_line` rejoins N's; the same name into `FN`; and
+ `X-ABShowAs:COMPANY`. `N` is emptied, `N:;;;;` as macOS wrote it: a
+ company has no person's name, and one kept in `N` would be a name no
+ screen shows, the editor hiding the pair for a company. A person
+ ticked a company leaves their names behind, prefixes and suffixes
+ too, and a switch back types them afresh, which Contacts.app asked
+ for as well. Each line is rewritten only
+ where it says something else, so an untouched save of the card macOS
+ wrote keeps every byte. A company whose `FN` disagrees with its
+ `ORG` (an imported "Bletchley Park" beside "Bletchley Park Trust")
+ is the exception: the field shows `ORG`'s name, and the save brings
+ `FN` into line with it, as macOS's own PUT did.
+- **A person** (`person_name`): `N` and `FN` as before, and
+ `X-ABShowAs` removed. `FN` is rewritten whenever the card was a
+ company, since the `FN` it had was the organization's name. `ORG` is
+ the person's employer, written from the same Organization field
+ (`edited_organization`), whose blank removes it — the whole-property
+ rule, department and all, those units having no row of their own. A
+ POST without the field leaves `ORG` alone, as one without the
+ birthday group leaves the birthday.
+
+`CardForm.nameless?` is the refusal both saves share, kind by kind:
+the Organization field for a company, either half of the pair for a
+person. "A contact needs a name." stands for both, as macOS's own
+refusal to save a nameless person did.
+
+The box submits `1` ticked, and a hidden `0` before it otherwise. A
+POST with neither keeps the card's kind, so the walk's and any other
+form's save cannot switch one by leaving the field out.
+
+## The form
+
+The rows read First, Middle, Last, Organization, then the Company box.
+Organization is one row for both kinds, since both keep it in `ORG`:
+required while the box is ticked, being the company's whole name, and
+otherwise an optional row like the nickname, wearing the editor's
+"removed on save" state over an `ORG` the card carries. That state is
+bound to the box, so a company's blank, which cannot save, never
+claims a removal. A company shows only that row and the box; the
+pair is rendered hidden and disabled (`x-show`, `:disabled`), so it
+neither submits nor holds the form back with a `required`, and a
+switch back mid-edit finds it as it was. The server's render sets all
+of it from the card, so the page is right before Alpine runs. The
+field's wire name is `organization`: it was `last` because the save
+wrote a company's name into N's family slot, which it no longer does.
+
+The field prefills from `ORG`'s first component. A company card
+without `ORG` is the older shape, which kept the name in `N` and `FN`,
+so it reads `FN`; a person without one gets an empty row, their own
+name being no organization's.
+
+## The details page
+
+`ORG` had no screen ("no screen renders `ORG`", the company-cards
+plan), which an editable row cannot leave it with. A person's
+organization reads under the name and nickname in the header, where
+Contacts.app puts it, in body type rather than the nickname's heading
+type because it is not a name of theirs. A company's is not repeated:
+it is the name above.
+
+The box is a Gloss Checkbox, not a Switch: Gloss reserves Switch for
+settings, and this is a fact about the record. Its caption is a
+`label for` beside it rather than one wrapping it, because Gloss sets
+a wrapping label in the checkbox's own type, over the editor's
+caption type.
+
+It sits on the edit page and not the create dialog, which this does
+not reopen: the dialog's reasons stand (`2026-09-24-company-cards.md`,
+"Creating"), and the page has none of them — no popover to resize, and
+a full form to swap rows in.
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 2d82f5e..5a4ff89 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -33,7 +33,35 @@ module ProTacts
)
end
- # The contact editor's save, spliced into the stored card.
+ # Whether the save writes a company card: the editor's Company
+ # box, which submits "1" checked and the hidden "0" before it
+ # otherwise. A POST carrying neither keeps what the card is
+ # (Contact#company?), so no other form's save can switch one.
+ #: (Contact contact, Hash[String, untyped] params) -> bool
+ def self.company?(contact, params)
+ case params["company"]
+ when "1" then true
+ when "0" then false
+ else contact.company?
+ end
+ end
+
+ # Whether the save is refused for want of a name, each kind
+ # asking its own: a company its organization field, a person
+ # either half of the pair (Admin::NamePair). The server's half
+ # of the browser's `required`.
+ #: (Contact contact, Hash[String, untyped] params) -> bool
+ def self.nameless?(contact, params)
+ if company?(contact, params)
+ params["organization"].to_s.strip.empty?
+ else
+ params["first"].to_s.strip.empty? && params["last"].to_s.strip.empty?
+ end
+ end
+
+ # The contact editor's save, spliced into the stored card. Where
+ # each kind keeps its name, and the switch between the two, is
+ # docs/plans/2026-10-07-switching-company-and-person.md.
#: (Contact contact, String first, String middle, String last, Hash[String, untyped] params) -> VCard
def self.contact_card(contact, first, middle, last, params)
nickname = params["nickname"].to_s.strip
@@ -47,8 +75,12 @@ module ProTacts
own = contact.own
vcard = contact.stored
- vcard = vcard.replace("N", [n_line(vcard, first, middle, last)])
- vcard = edited_fn(contact, vcard, first, middle, last)
+ vcard =
+ if company?(contact, params)
+ company_name(vcard, params["organization"].to_s.strip)
+ else
+ person_name(contact, vcard, first, middle, last, params["organization"]&.to_s&.strip)
+ end
vcard = vcard.replace("NICKNAME", text_lines("NICKNAME", nickname))
vcard = vcard.replace("NOTE", text_lines("NOTE", note))
vcard = edited_values(own.phones, "phone", "TEL", vcard, params)
@@ -236,6 +268,65 @@ module ProTacts
["#{name}:#{VCard.escape(value)}\r\n"]
end
+ # A company's name, written where macOS writes one (exchange
+ # ec5ce1d8dd03 in log/dev.log, 2026-10-07): ORG's first component
+ # and FN both, under X-ABShowAs:COMPANY, with N empty. A company
+ # has no person's name, and one left in N would be a name no
+ # screen here shows (the editor hides the pair for a company), so
+ # a person made a company leaves theirs behind, prefixes and
+ # suffixes too, and a switch back is typed afresh, as Contacts.app
+ # asked for it. Each line is rewritten only where it says
+ # something else, so an untouched save of the card macOS wrote
+ # keeps its bytes.
+ #: (VCard vcard, String organization) -> VCard
+ def self.company_name(vcard, organization)
+ vcard = vcard.replace("N", ["N:;;;;\r\n"]) unless vcard.property("N")&.components&.all?(&:empty?)
+ vcard = vcard.replace("FN", ["FN:#{VCard.escape(organization)}\r\n"]) unless vcard.property("FN")&.text == organization
+ vcard = edited_organization(vcard, organization)
+ return vcard if vcard.property("X-ABShowAs")&.text == "COMPANY"
+
+ vcard.replace("X-ABShowAs", ["X-ABShowAs:COMPANY\r\n"])
+ end
+
+ # A person's name, into N and FN, and the flag gone: a person is
+ # a card without X-ABShowAs, which is how macOS unticks Company
+ # (exchange b5ce7eaebe0e). ORG is the person's employer, written
+ # from the same field as a company's name; a POST without that
+ # field leaves it be, the birthday group's posture.
+ #: (Contact contact, VCard vcard, String first, String middle, String last, String? organization) -> VCard
+ def self.person_name(contact, vcard, first, middle, last, organization)
+ vcard = vcard.replace("N", [n_line(vcard, first, middle, last)])
+ vcard = edited_fn(contact, vcard, first, middle, last)
+ vcard = edited_organization(vcard, organization) if organization
+ vcard.replace("X-ABShowAs", [])
+ end
+
+ # An organization's own name: ORG's first component (RFC 2426
+ # section 3.5.5), the units after it being no part of it. Public
+ # for the editor's prefill (Admin::ContactsEdit), which reads the
+ # name this save writes.
+ #: (VCard vcard) -> String?
+ def self.organization_of(vcard)
+ vcard.property("ORG")&.components&.first
+ end
+
+ # ORG with the organization field's name in it, or the card
+ # untouched where the name already is that. The name is the first
+ # component, the units after it rejoined byte for byte — n_line's
+ # splice, for n_line's reason. Blank removes the property, the
+ # whole-property rule, units and all: they are no row of their own
+ # here, and a department of no organization is not one to keep.
+ #: (VCard vcard, String organization) -> VCard
+ def self.edited_organization(vcard, organization)
+ return vcard if organization_of(vcard).to_s == organization
+ return vcard.replace("ORG", []) if organization.empty?
+
+ property = vcard.property("ORG")
+ units = [] #: Array[String]
+ units = VCard.split_raw_components(property.value).drop(1) if property
+ vcard.replace("ORG", ["ORG:#{[VCard.escape(organization), *units].join(";")}\r\n"])
+ end
+
# FN's replacement, or the card untouched: the display name is the
# card's own text (RFC 2426 section 3.1.1), not a rendering of N,
# and "Dr. Ada B. Lovelace, Jr." holds parts no field on this form
@@ -250,7 +341,9 @@ module ProTacts
def self.edited_fn(contact, vcard, first, middle, last)
family, given, additional = contact.name_components || []
named = !vcard.property("FN").nil?
- return vcard if named && first == given.to_s && middle == additional.to_s && last == family.to_s
+ # A company's FN is its organization's name (#company_name), so
+ # a card leaving that kind takes its display name from the pair.
+ return vcard if named && !contact.company? && first == given.to_s && middle == additional.to_s && last == family.to_s
vcard.replace("FN", ["FN:#{VCard.escape(display_name(first, middle, last))}\r\n"])
end
@@ -296,7 +389,7 @@ module ProTacts
private_class_method :edited_values, :edited_addresses, :address_unchanged?,
:address_line, :birthday_component, :text_lines, :edited_fn, :display_name,
- :n_line
+ :n_line, :company_name, :person_name, :edited_organization
end
end
end
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index ec32def..5bdc672 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -1,6 +1,7 @@
require "pro_tacts/admin/phlex"
require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/card_form"
require "pro_tacts/admin/format"
require "pro_tacts/admin/group_dialog"
require "pro_tacts/admin/group_label"
@@ -63,6 +64,13 @@ module ProTacts
if @contact.nickname
div(class: "type-h3 gl-muted", style: "margin-top: var(--gl-space-2xs);") { @contact.nickname }
end
+ # A person's organization, where Contacts.app shows it:
+ # under the name, in body type because it is no name of
+ # theirs. A company's is the name above it already.
+ organization = CardForm.organization_of(@contact.vcard).to_s
+ if !@contact.company? && !organization.empty?
+ div(class: "type-body gl-muted", style: "margin-top: var(--gl-space-2xs);") { organization }
+ end
end
# Only a picture earns this slot: an initials circle
# beside the name in type-h2 would repeat what the name
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 236c282..1220949 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -80,6 +80,7 @@ module ProTacts
# @rbs @first: String?
# @rbs @middle: String?
# @rbs @last: String?
+ # @rbs @organization: String?
# @rbs @action: String
# @rbs @back: ([String, String])?
# @rbs @aside: Phlex::HTML?
@@ -128,7 +129,8 @@ module ProTacts
family, given, additional = contact.name_components || []
@first = given
@middle = additional
- @last = family || organization_name
+ @last = family
+ @organization = organization_name
end
def view_template
@@ -142,8 +144,9 @@ module ProTacts
# row for it, so the two have to share state, and Alpine
# scopes by ancestry. `added` is the list of types added
# this pass, `type` is the radio's binding, and the rest is
- # the name boxes' (NamePair).
- div(x_data: "{ added: [], type: '#{ADDABLE_TYPES.first}', #{NamePair.state(@first, @last)} }") do
+ # the name boxes' (NamePair), `company` the Company box's.
+ div(x_data: "{ added: [], type: '#{ADDABLE_TYPES.first}', company: #{@contact.company?}, " \
+ "#{NamePair.state(@first, @last)} }") do
render RecordCard.new(
back: @back,
nav: -> {
@@ -165,39 +168,64 @@ module ProTacts
# would still land in the type column as an
# anonymous grid item, but nothing could then reach
# it, and the note's caption needs reaching.
- # The name, as an organization spells it or a person:
- # one field for a company card (X-ABShowAs:COMPANY,
- # Contact#company?), the pair and the middle box for
- # everyone else. The organization field's wire name
- # is `last` because that is where the name lives —
- # N's family slot, the component the save splices
- # (CardForm.n_line) — and the label says what the
- # visitor knows it by. An unchecked company card
- # submits no first or middle, and the save reads
- # their absence as the empties they are.
- if @contact.company?
- label(class: "field") do
- span { "Organization" }
- input(type: "text", name: "last", value: @last, required: true, autofocus: true)
- end
- else
- label(class: "field") do
- span { "First" }
- input(**NamePair.first(@first, @last), autofocus: true)
- end
- # Outside the required pair, and outside its removal
- # state with it: a blanked middle name rewrites N's
- # additional component like any other name box, and
- # the row it would strike through is the name row,
- # which no save can remove.
- label(class: "field") do
- span { "Middle" }
- input(type: "text", name: "middle", value: @middle)
- end
- label(class: "field") do
- span { "Last" }
- input(**NamePair.last(@first, @last))
- end
+ # The name, as a person spells it: the pair and the
+ # middle box, hidden for a company, whose name is the
+ # organization row below. Disabled rather than only
+ # hidden, so they neither submit nor stand in the way
+ # of a `required`, and a switch back before the save
+ # finds them as they were; a saved company has none
+ # (CardForm.company_name). Shown and enabled from the
+ # server's own render, so the page is right before
+ # Alpine runs.
+ company = @contact.company?
+ label(class: "field", x_show: "!company", style: ("display: none" if company)) do
+ span { "First" }
+ input(**NamePair.first(@first, @last), disabled: company, ":disabled": "company",
+ autofocus: !company)
+ end
+ # Outside the required pair, and outside its removal
+ # state with it: a blanked middle name rewrites N's
+ # additional component like any other name box, and
+ # the row it would strike through is the name row,
+ # which no save can remove.
+ label(class: "field", x_show: "!company", style: ("display: none" if company)) do
+ span { "Middle" }
+ input(type: "text", name: "middle", value: @middle, disabled: company, ":disabled": "company")
+ end
+ label(class: "field", x_show: "!company", style: ("display: none" if company)) do
+ span { "Last" }
+ input(**NamePair.last(@first, @last), disabled: company, ":disabled": "company")
+ end
+ # ORG, one row for both kinds: a company's name, and
+ # so required while the box is ticked, or a person's
+ # employer, and so a whole-property row like the
+ # nickname, whose blank removes it. The removal state
+ # is bound to the box so a company's blank, which
+ # cannot save, never claims a removal.
+ org = !@contact.stored.property("ORG").nil?
+ label(class: "field", data: {blank_removes: org && !company},
+ **(org ? {":data-blank-removes": "!company"} : {})) do
+ span { "Organization" }
+ input(type: "text", name: "organization", value: @organization,
+ required: company, ":required": "company", autofocus: company,
+ placeholder: org && !company ? "removed on save" : nil,
+ **(org ? {":placeholder": "company ? '' : 'removed on save'"} : {}))
+ end
+ # Company or person, Contacts.app's own checkbox
+ # (docs/plans/2026-10-07-switching-company-and-
+ # person.md), under the name it decides the shape of.
+ # The hidden "0" goes first so an unticked box still
+ # says so — Rack keeps the last of a repeated name —
+ # and CardForm.company? reads a POST with neither as
+ # the card's kind unchanged. The caption is a label
+ # beside the box rather than one wrapping it: Gloss
+ # sets a wrapping label in its checkbox type, over
+ # the caption's.
+ div(class: "field") do
+ label(for: "company") { "Company" }
+ input(type: "hidden", name: "company", value: "0")
+ input(type: "checkbox", id: "company", name: "company", value: "1",
+ checked: company, x_model: "company")
end
# The whole-property rows wear the removal state only
# over a property the card carries: both render empty
@@ -248,18 +276,16 @@ module ProTacts
private
- # A company card's name, from wherever the card holds it. macOS
- # writes N empty and the name in FN and ORG both (the PUT a real
- # client sent, log/dev.log 2026-09-24), so the organization
- # field cannot prefill from the family slot alone; the older
- # family-slot shape is already read by then, and ORG's own name
- # is its first component — the semicolons after it are units.
- # nil for a person, whose family slot is the field.
+ # The organization field's value: ORG's own name, where the save
+ # writes it (CardForm.edited_organization). A company card without
+ # one is the older shape, which kept a company's name in N and
+ # FN, so it reads FN; a person without one has no employer.
#: () -> String?
def organization_name
- return nil unless @contact.company?
+ own = CardForm.organization_of(@contact.stored).to_s
+ return own unless own.empty?
- @contact.name || @contact.organization&.split(";", 2)&.first
+ @contact.name if @contact.company?
end
# The rows added this pass, rendered by Alpine from `added` —
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 359119a..c6f53f4 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -158,11 +158,12 @@ module ProTacts
# N and FN are mandatory (RFC 2426 section 4), so a save blank
# throughout is refused — the toast is the backstop, the form's
# name pair (Admin::NamePair) being the browser's own refusal of
- # the same.
+ # the same, and a company's organization field the one name it
+ # has (Admin::CardForm.nameless?).
first = r.params["first"].to_s.strip
middle = r.params["middle"].to_s.strip
last = r.params["last"].to_s.strip
- return refuse.call(contact, notice: "A contact needs a name.") if first.empty? && last.empty?
+ return refuse.call(contact, notice: "A contact needs a name.") if Admin::CardForm.nameless?(contact, r.params)
# Request validity, standing with the name check rather than the
# stored-state conditionals below (write_card's ordering rule). A
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index b741266..15bdbc3 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -428,11 +428,12 @@ module ProTacts
# N and FN are mandatory (RFC 2426 section 4), so a save blank
# throughout is refused — the toast is the backstop, the form's
# name pair (Admin::NamePair) being the browser's own refusal of
- # the same.
+ # the same, and a company's organization field the one name it
+ # has (Admin::CardForm.nameless?).
first = r.params["first"].to_s.strip
middle = r.params["middle"].to_s.strip
last = r.params["last"].to_s.strip
- if first.empty? && last.empty?
+ if Admin::CardForm.nameless?(contact, r.params)
return card_screen(upload, index, into:, notice: "A contact needs a name.", joined: ticked, named: wanted)
end
diff --git a/public/admin.css b/public/admin.css
index 30a518e..f8edb76 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -183,6 +183,12 @@
column-gap: var(--gl-space-s);
align-items: baseline;
}
+/* The Company row's box has no text to share a baseline with, so the
+ baseline rule above floats it over its caption; centred, the two
+ read as one row like every other. */
+.card-body form.field-stack > .field:has(> input[type="checkbox"]) {
+ align-items: center;
+}
/* A row whose control is a stack of inputs — an address's component
fields — holds the multi-line value's own lines in the value
column (docs/DESIGN.md), so the stack closes to the record's own
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 77e0007..f0f7261 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1350,10 +1350,10 @@ class AdminContactsPagesTest < Minitest::Test
assert_includes body, '<form action="/contacts/red" method="post" class="field-stack" id="contact-form">'
assert_includes body, '<button type="submit" form="contact-form" data-variant="primary">Save</button>'
assert_includes body, '<input type="text" name="first" value="Ada" ' \
- ':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus>'
- assert_includes body, '<span>Middle</span><input type="text" name="middle">'
+ ':required="lastBlank" @input="firstBlank = !$el.value.trim()" :disabled="company" autofocus>'
+ assert_includes body, '<span>Middle</span><input type="text" name="middle" :disabled="company">'
assert_includes body, '<input type="text" name="last" value="Lovelace" ' \
- ':required="firstBlank" @input="lastBlank = !$el.value.trim()">'
+ ':required="firstBlank" @input="lastBlank = !$el.value.trim()" :disabled="company">'
assert_includes body,
'<label class="field" data-blank-removes><span>Nickname</span>' \
'<input type="text" name="nickname" value="Red" placeholder="removed on save"></label>'
@@ -1373,7 +1373,7 @@ class AdminContactsPagesTest < Minitest::Test
get "/contacts/ada/edit"
assert_equal 200, last_response.status
- assert_includes last_response.body, '<input type="text" name="middle" value="Byron, the poet">'
+ assert_includes last_response.body, '<input type="text" name="middle" value="Byron, the poet" :disabled="company">'
end
end
@@ -1388,9 +1388,9 @@ class AdminContactsPagesTest < Minitest::Test
body = last_response.body
assert_includes body, "firstBlank: true, lastBlank: false"
assert_includes body, '<input type="text" name="first" ' \
- ':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus>'
+ ':required="lastBlank" @input="firstBlank = !$el.value.trim()" :disabled="company" autofocus>'
assert_includes body, '<input type="text" name="last" value="Prince" required ' \
- ':required="firstBlank" @input="lastBlank = !$el.value.trim()">'
+ ':required="firstBlank" @input="lastBlank = !$el.value.trim()" :disabled="company">'
post "/contacts/prince", first: "", last: "Prince", etag: store.contact("prince").etag
@@ -1399,71 +1399,195 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- ACME = "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Acme Corp;;;;\r\nFN:Acme Corp\r\nORG:Acme Corp\r\n" \
+ ACME = "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Acme Corp;;;;\r\nFN:Acme Corp\r\nORG:Acme Corp;Sales\r\n" \
"X-ABShowAs:COMPANY\r\nUID:acme\r\nEND:VCARD\r\n"
- # A company card's name is one field — the organization's own — in
- # place of the pair, keyed off X-ABShowAs:COMPANY; the wire name is
- # `last`, the family slot the save splices.
+ # The two cards macOS PUT as a card was switched in Contacts.app
+ # (log/dev.log, 2026-10-07): a person with an employer (exchange
+ # b5ce7eaebe0e) and that card made a company again (ec5ce1d8dd03).
+ # Their X-ADDRESSING-GRAMMAR blob is left out, being Apple's opaque
+ # bytes that no read here touches.
+ SWITCHED_PERSON = "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//Apple Inc.//macOS 27.0//EN\r\nN:B;A;;;\r\n" \
+ "FN:A B\r\nORG:Arbitrary Definitions;\r\nREV:2026-10-07T13:34:15Z\r\nUID:company\r\n" \
+ "END:VCARD\r\n"
+ SWITCHED_COMPANY = "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//Apple Inc.//macOS 27.0//EN\r\nN:;;;;\r\n" \
+ "FN:Arbitrary Definitions\r\nORG:Arbitrary Definitions;\r\nX-ABShowAs:COMPANY\r\n" \
+ "REV:2026-10-07T13:34:40Z\r\nUID:company\r\nEND:VCARD\r\n"
+
+ # A company card's name is the organization row, required and
+ # focused; the pair is rendered hidden and disabled, so the Company
+ # box can switch to it and it neither submits nor blocks a save.
def test_the_edit_screen_shows_one_organization_field_for_a_company_card
with_contacts({"acme" => ACME}) do
get "/contacts/acme/edit"
body = last_response.body
assert_equal 200, last_response.status
- assert_includes body, '<span>Organization</span>' \
- '<input type="text" name="last" value="Acme Corp" required autofocus>'
- refute_includes body, '<span>First</span>'
- refute_includes body, '<span>Last</span>'
+ assert_includes body, '<div class="field"><label for="company">Company</label>' \
+ '<input type="hidden" name="company" value="0">' \
+ '<input type="checkbox" id="company" name="company" value="1" checked x-model="company">'
+ assert_includes body, %(<label class="field" :data-blank-removes="!company"><span>Organization</span>) +
+ %(<input type="text" name="organization" value="Acme Corp" required :required="company" ) +
+ %(autofocus :placeholder="company ? '' : 'removed on save'">)
+ assert_includes body, '<label class="field" x-show="!company" style="display: none"><span>First</span>'
+ assert_includes body, '<input type="text" name="last" value="Acme Corp" required ' \
+ ':required="firstBlank" @input="lastBlank = !$el.value.trim()" disabled :disabled="company">'
+ # The organization, then the box, under the name fields.
+ assert_operator body.index('name="organization"'), :>, body.index('name="last"')
+ assert_operator body.index('id="company"'), :>, body.index('name="organization"')
+ end
+ end
+
+ # A person's organization is their employer: shown, optional, and a
+ # whole-property row whose blank removes it, like the nickname's.
+ def test_the_edit_screen_shows_a_persons_organization
+ with_contacts({"company" => SWITCHED_PERSON}) do
+ get "/contacts/company/edit"
+
+ body = last_response.body
+ assert_includes body, '<input type="checkbox" id="company" name="company" value="1" x-model="company">'
+ assert_includes body, %(<label class="field" data-blank-removes :data-blank-removes="!company">) +
+ %(<span>Organization</span><input type="text" name="organization" ) +
+ %(value="Arbitrary Definitions" :required="company" placeholder="removed on save" ) +
+ %(:placeholder="company ? '' : 'removed on save'">)
+ end
+ end
+
+ # A person without one has an empty row and nothing to lose, and
+ # their own name is no organization's to prefill.
+ def test_a_person_without_an_organization_gets_an_empty_row
+ with_contacts({"ada" => ADA}) do
+ get "/contacts/ada/edit"
+
+ assert_includes last_response.body,
+ '<label class="field"><span>Organization</span><input type="text" name="organization" :required="company">'
+ end
+ end
+
+ # The person's save writes the employer into ORG's first component,
+ # the units after it kept; blank removes the property; a POST
+ # without the field leaves it be.
+ def test_saving_a_persons_organization
+ with_contacts({"company" => SWITCHED_PERSON.sub("ORG:Arbitrary Definitions;", "ORG:Arbitrary Definitions;Ops")}) do |store|
+ post "/contacts/company", first: "A", last: "B", organization: "Gloss", etag: store.contact("company").etag
+ assert_includes store.contact("company").vcard.to_s, "ORG:Gloss;Ops\r\n"
+
+ post "/contacts/company", first: "A", last: "B", etag: store.contact("company").etag
+ assert_includes store.contact("company").vcard.to_s, "ORG:Gloss;Ops\r\n"
+
+ post "/contacts/company", first: "A", last: "B", organization: "", etag: store.contact("company").etag
+ refute_includes store.contact("company").vcard.to_s, "ORG"
+ refute store.contact("company").company?
+ end
+ end
+
+ # The details page shows a person's organization under their name,
+ # and a company's not twice.
+ def test_show_puts_a_persons_organization_under_the_name
+ with_contacts({"company" => SWITCHED_PERSON, "acme" => ACME}) do
+ get "/contacts/company"
+ header = last_response.body.split("<dl").first
+ assert_includes header, '<div class="type-body gl-muted" style="margin-top: var(--gl-space-2xs);">' \
+ "Arbitrary Definitions</div>"
+
+ get "/contacts/acme"
+ refute_includes last_response.body.split("<dl").first, "type-body gl-muted"
end
end
- # The save splices the organization's name into N's family slot and
- # rewrites FN, and touches nothing else: ORG is a property no field
- # on this form owns, so it keeps the bytes it arrived with.
- def test_saving_a_company_cards_edit_moves_the_name_and_leaves_its_org_alone
+ # A company's name goes into ORG's first component and FN, where
+ # macOS keeps it; the units after it keep their bytes, and N is
+ # emptied, the older shape's name in it included.
+ def test_saving_a_company_writes_its_name_into_org_and_fn
with_contacts({"acme" => ACME}) do |store|
- post "/contacts/acme", last: "Acme Corporation", etag: store.contact("acme").etag
+ post "/contacts/acme", company: "1", organization: "Acme, Inc.", etag: store.contact("acme").etag
assert_equal 303, last_response.status
card = store.contact("acme").vcard.to_s
- assert_includes card, "N:Acme Corporation;;;;\r\n"
- assert_includes card, "FN:Acme Corporation\r\n"
- assert_includes card, "ORG:Acme Corp\r\n"
+ assert_includes card, "N:;;;;\r\n"
+ assert_includes card, "FN:Acme\\, Inc.\r\n"
+ assert_includes card, "ORG:Acme\\, Inc.;Sales\r\n"
assert_includes card, "X-ABShowAs:COMPANY\r\n"
end
end
- # The card a real client sends: macOS writes a company's N empty
- # and its name in FN and ORG both (the PUT of 2026-09-24 in
- # log/dev.log), so the organization field prefills from FN — not
- # the family slot the older shape carried it in — and an untouched
- # save writes the name into N while ORG keeps its own bytes.
- def test_a_company_card_whose_name_sits_in_fn_prefills_and_saves
- client =
- "BEGIN:VCARD\r\nVERSION:3.0\r\nN:;;;;\r\nFN:Arbitrary Definitions\r\n" \
- "ORG:Arbitrary Definitions;\r\nX-ABShowAs:COMPANY\r\n" \
- "UID:client-org\r\nEND:VCARD\r\n"
+ # An untouched save of the card macOS wrote keeps every byte.
+ def test_an_untouched_company_save_keeps_the_card
+ with_contacts({"company" => SWITCHED_COMPANY}) do |store|
+ get "/contacts/company/edit"
+ assert_includes last_response.body, 'name="organization" value="Arbitrary Definitions"'
- with_contacts({"client-org" => client}) do |store|
- get "/contacts/client-org/edit"
+ post "/contacts/company", company: "1", organization: "Arbitrary Definitions",
+ etag: store.contact("company").etag
- body = last_response.body
- assert_equal 200, last_response.status
- assert_includes body, '<span>Organization</span>' \
- '<input type="text" name="last" value="Arbitrary Definitions" required autofocus>'
+ assert_equal 303, last_response.status
+ assert_equal SWITCHED_COMPANY, store.contact("company").vcard.to_s
+ end
+ end
- post "/contacts/client-org", last: "Arbitrary Definitions",
- etag: store.contact("client-org").etag
+ # Ticking Company on a person: the flag goes on and FN takes the
+ # organization's name, and N is emptied, as in macOS's second PUT:
+ # the person's names would otherwise stay on a card that no longer
+ # shows them.
+ def test_ticking_company_makes_a_person_a_company
+ with_contacts({"company" => SWITCHED_PERSON}) do |store|
+ post "/contacts/company", company: "1", organization: "Arbitrary Definitions",
+ etag: store.contact("company").etag
assert_equal 303, last_response.status
- card = store.contact("client-org").vcard.to_s
- assert_includes card, "N:Arbitrary Definitions;;;;\r\n"
+ contact = store.contact("company")
+ assert contact.company?
+ card = contact.vcard.to_s
+ assert_includes card, "N:;;;;\r\n"
assert_includes card, "FN:Arbitrary Definitions\r\n"
assert_includes card, "ORG:Arbitrary Definitions;\r\n"
end
end
+ # Unticking it: the flag comes off and the pair writes N and FN, as
+ # in macOS's first PUT; ORG stays, the person's employer now.
+ def test_unticking_company_makes_a_company_a_person
+ with_contacts({"company" => SWITCHED_COMPANY}) do |store|
+ post "/contacts/company", company: "0", first: "A", last: "B", etag: store.contact("company").etag
+
+ assert_equal 303, last_response.status
+ contact = store.contact("company")
+ refute contact.company?
+ card = contact.vcard.to_s
+ assert_includes card, "N:B;A;;;\r\n"
+ assert_includes card, "FN:A B\r\n"
+ assert_includes card, "ORG:Arbitrary Definitions;\r\n"
+ refute_includes card, "X-ABShowAs"
+ end
+ end
+
+ # A company switched to a person whose N already holds names still
+ # gets its FN from them: the FN it had was the organization's.
+ def test_a_switch_back_to_a_person_rewrites_the_display_name
+ names_kept = SWITCHED_COMPANY.sub("N:;;;;", "N:B;A;;;")
+
+ with_contacts({"company" => names_kept}) do |store|
+ post "/contacts/company", company: "0", first: "A", last: "B", etag: store.contact("company").etag
+
+ assert_includes store.contact("company").vcard.to_s, "FN:A B\r\n"
+ end
+ end
+
+ # Neither kind saves without its name: macOS would not save the
+ # person it unticked until one was typed, and a company's one field
+ # is the whole of its name.
+ def test_a_switch_without_the_new_kinds_name_is_refused
+ with_contacts({"company" => SWITCHED_COMPANY, "person" => SWITCHED_PERSON.sub("UID:company", "UID:person")}) do |store|
+ post "/contacts/company", company: "0", first: "", last: "", etag: store.contact("company").etag
+ assert_includes last_response.body, "A contact needs a name."
+ assert store.contact("company").company?
+
+ post "/contacts/person", company: "1", organization: " ", etag: store.contact("person").etag
+ assert_includes last_response.body, "A contact needs a name."
+ refute store.contact("person").company?
+ end
+ end
+
# The editor renders the contact's own rows and none a group lends:
# a row here is a row the save may rewrite or remove, and neither is
# this screen's to do to a value the household owns. The details
@@ -2341,7 +2465,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => ADA}) do
body = (get("/contacts/ada/edit") && last_response.body)
- assert_includes body, %(<div x-data="{ added: [], type: 'phone', firstBlank: false, lastBlank: false }">)
+ assert_includes body, %(<div x-data="{ added: [], type: 'phone', company: false, firstBlank: false, lastBlank: false }">)
# One template serves every kind: the single-value kinds get an
# input whose type and name bind to the kind, the address kind
# the same six component fields a standing row gets — named by
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 3d19452..3a2f60f 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -280,7 +280,9 @@ class AdminImportPagesTest < Minitest::Test
# The shape macOS writes a company in (test/fixtures/cards/
# company.vcf): N empty, the name in FN and ORG, and the flag. It
- # opens in the company editor and comes in as a company.
+ # opens in the company editor and comes in as a company, its name
+ # ORG's own and FN following it the way macOS writes FN
+ # (docs/plans/2026-10-07-switching-company-and-person.md).
def test_a_company_card_comes_in_as_a_company
company = <<~CARD.gsub("\n", "\r\n")
BEGIN:VCARD
@@ -299,15 +301,16 @@ class AdminImportPagesTest < Minitest::Test
refute_includes last_response.body, "not imported"
assert_includes last_response.body, "<span>Organization</span>"
- assert_includes last_response.body, %(name="last" value="Bletchley Park")
+ assert_includes last_response.body, %(name="organization" value="Bletchley Park Trust")
- save(id, 0, first: "", last: "Bletchley Park")
+ save(id, 0, first: "", last: "", company: "1", organization: "Bletchley Park Trust")
assert_equal 303, last_response.status
park = store.contacts.fetch(0)
assert_predicate park, :company?
assert_equal "Bletchley Park Trust;", park.organization
+ assert_equal "Bletchley Park Trust", park.name
end
end