diff --git a/client/src/components/Planner/PlaceFormModal.helpers.ts b/client/src/components/Planner/PlaceFormModal.helpers.ts index 46ef9b9f1..98fe596dc 100644 --- a/client/src/components/Planner/PlaceFormModal.helpers.ts +++ b/client/src/components/Planner/PlaceFormModal.helpers.ts @@ -410,3 +410,15 @@ export function formPin(form: Pick): { lat: number if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null return { lat, lng } } + +/** + * The category a picked place already carries, as the form's value, when this server + * has it. A place saved in a collection keeps its category_id, and categories are the + * server's own, so it fits the trip as it stands. Guessing from the place's kind + * instead dropped it (#3074). + */ +export function savedCategoryId(result: Record, categories: readonly { id: number }[]): string | null { + const id = result.category_id + if (typeof id !== 'number') return null + return categories.some(c => c.id === id) ? String(id) : null +} diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index a9669091f..368c38965 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -2378,6 +2378,36 @@ describe('PlaceFormModal on the dialog frame', () => { expect(onClose).not.toHaveBeenCalled(); }); + it('FE-PLANNER-PLACEFORM-125: a place picked from a collection keeps the category it was saved under (#3074)', async () => { + withCollections(); + vi.spyOn(collectionsApi, 'get').mockResolvedValue({ + collection: { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }, + places: [ + { + id: 10, + collection_id: 1, + name: 'Zebra Cafe', + status: 'idea', + lat: 35.66, + lng: 139.7, + category_id: 7, + category: { id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' }, + }, + ], + }); + const onSave = vi.fn().mockResolvedValue(undefined); + // A cafe saved as "Must see": the guess from its kind would be the cafe category. + const categories = [ + { id: 3, name: 'Cafe', color: '#7c3aed', icon: 'Coffee' }, + { id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' }, + ]; + render(); + fireEvent.click(await screen.findByText('Zebra Cafe')); + fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Zebra Cafe', category_id: '7' })); + }); + it('FE-PLANNER-PLACEFORM-099: Escape with nothing open closes the dialog', () => { const onClose = vi.fn(); render(); diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 556ca8684..c21349e78 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -19,7 +19,7 @@ import { weekFromPeriods } from './placeHours' import type { PlaceOpeningHours } from '@trek/shared' import { DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload, - prefillForm, type PlaceFormData, type PlacePrefill, + prefillForm, savedCategoryId, type PlaceFormData, type PlacePrefill, } from './PlaceFormModal.helpers' import { useNewCategory, usePlaceForm } from './usePlaceForm' import { usePlaceSearch } from './usePlaceSearch' @@ -233,6 +233,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) { setForm(prev => { const merged = mergeResult(prev, result, autoFilledRef.current) if (prev.category_id && !categoryGuessedRef.current) return merged + // A place from a collection brings the category it was saved under (#3074). + const saved = savedCategoryId(result, categories || []) + if (saved) { + categoryGuessedRef.current = false + return { ...merged, category_id: saved } + } return { ...merged, category_id: guessedCategory(result) } }) setPickedSource(label)