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(", ");