fix: keep a category typed without Enter when the contact is saved

This commit is contained in:
Linus Rath
2026-10-28 10:09:57 +01:00
parent a80a434cca
commit bae76bcc4e
2 changed files with 40 additions and 8 deletions
@@ -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(<ContactForm contact={existingContact} allKeywords={['Friends']} onSave={onSave} onCancel={vi.fn()} />);
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(<ContactForm contact={existingContact} onSave={vi.fn()} onCancel={vi.fn()} onDirtyChange={onDirtyChange} />);
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' };
+20 -8
View File
@@ -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<string, boolean> = {};
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
<CategoryComboBox
keywordsStr={keywordsStr}
onChange={setKeywordsStr}
inputValue={categoryDraft}
onInputChange={setCategoryDraft}
allKeywords={allKeywords || []}
placeholder={t("categories_placeholder")}
hint={t("categories_hint")}
@@ -1201,6 +1209,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
function CategoryComboBox({
keywordsStr,
onChange,
inputValue,
onInputChange: setInputValue,
allKeywords,
placeholder,
hint,
@@ -1208,6 +1218,9 @@ function CategoryComboBox({
}: {
keywordsStr: string;
onChange: (value: string) => 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<HTMLInputElement>(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(", ");