From bae76bcc4ee2c64306a4bd5dc26bb98ce1e29f9c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:09:57 +0100 Subject: [PATCH] fix: keep a category typed without Enter when the contact is saved --- .../contacts/__tests__/contact-form.test.tsx | 20 +++++++++++++ components/contacts/contact-form.tsx | 28 +++++++++++++------ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index 1f763d853..c6d304ba2 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -86,6 +86,26 @@ describe('ContactForm', () => { expect(savedData.media).toBeNull(); }); + it('adds a category typed without Enter when the contact is saved, in the spelling of an existing one', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByText('categories')); + fireEvent.change(screen.getByPlaceholderText('categories_placeholder'), { target: { value: 'friends, Gym ' } }); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); + expect(onSave.mock.calls[0][0].keywords).toEqual({ Friends: true, Gym: true }); + }); + + it('counts a typed category as a change, so leaving asks first', () => { + const onDirtyChange = vi.fn(); + render(); + fireEvent.click(screen.getByText('categories')); + fireEvent.change(screen.getByPlaceholderText('categories_placeholder'), { target: { value: 'Gym' } }); + expect(onDirtyChange).toHaveBeenLastCalledWith(true); + }); + describe('photos (P0-3)', () => { // The same PHOTO as ContactCard/get returns it on each version. const photo0_16_23: ContactMedia = { kind: 'photo', uri: 'data:image/jpeg;base64,/9j/', mediaType: 'image/jpeg' }; diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 41e4903b3..995eb1f2d 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -398,6 +398,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const [keywordsStr, setKeywordsStr] = useState( contact?.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]).join(", ") : "" ); + // Text typed in the Categories field but not added with Enter yet: Save + // adds it, so it isn't lost. + const [categoryDraft, setCategoryDraft] = useState(""); const [note, setNote] = useState( contact?.notes ? Object.values(contact.notes)[0]?.note || "" : "" @@ -455,7 +458,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const fields = JSON.stringify([ prefix, givenName, additionalName, surname, suffix, nickname, emails, phones, organization, department, isOrg, jobTitle, role, addresses, onlineServices, anniversaries, personalInfoEntries, - keywordsStr, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId, + keywordsStr, categoryDraft, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId, ]); const [initialFields] = useState(fields); const dirty = fields !== initialFields || photoEdit.kind !== "unchanged"; @@ -614,11 +617,14 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress }); const keywordsMap: Record = {}; - if (keywordsStr.trim()) { - keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => { - keywordsMap[k] = true; - }); - } + keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => { + keywordsMap[k] = true; + }); + // A typed category that matches an existing one takes its spelling. + categoryDraft.split(",").map(k => k.trim()).filter(Boolean).forEach(k => { + const existing = (allKeywords || []).find(kw => kw.toLowerCase() === k.toLowerCase()); + keywordsMap[existing ?? k] = true; + }); // Only send `kind` when this form owns the answer: switching a card between // person and organization. Leave other kinds (group, location, ...) untouched. @@ -1126,6 +1132,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress void; + /** The text typed but not added yet; the form adds it on Save. */ + inputValue: string; + onInputChange: (value: string) => void; allKeywords: string[]; placeholder: string; hint: string; @@ -1215,7 +1228,6 @@ function CategoryComboBox({ }) { const t = useTranslations("contacts.form"); const [isOpen, setIsOpen] = useState(false); - const [inputValue, setInputValue] = useState(""); const inputRef = useRef(null); // Parse current keywords from comma-separated string @@ -1243,7 +1255,7 @@ function CategoryComboBox({ const next = [...currentKeywords, trimmed].join(", "); onChange(next); setInputValue(""); - }, [currentKeywords, onChange]); + }, [currentKeywords, onChange, setInputValue]); const removeKeyword = useCallback((keyword: string) => { const next = currentKeywords.filter(k => k !== keyword).join(", ");