diff --git a/client/src/components/Planner/PlaceContactFields.tsx b/client/src/components/Planner/PlaceContactFields.tsx index e0c96516a..b18ceb6d3 100644 --- a/client/src/components/Planner/PlaceContactFields.tsx +++ b/client/src/components/Planner/PlaceContactFields.tsx @@ -9,6 +9,8 @@ import { readWeek, weekdayNames, writeWeek } from './placeHours' import { useTranslation } from '../../i18n' interface Props { + /** Anchor for the "add by hand" jump from an empty search. */ + id?: string phone: string email: string /** The stored hours text (JSON), empty when the place has none of its own. */ @@ -23,7 +25,7 @@ interface Props { * behind one row until somebody wants them, so a place without any does not * carry a seven-row grid. */ -export function PlaceContactFields({ phone, email, openingHours, onChange }: Props) { +export function PlaceContactFields({ id: anchorId, phone, email, openingHours, onChange }: Props) { const { t, locale } = useTranslation() const id = useId() const week = readWeek(openingHours) @@ -38,7 +40,7 @@ export function PlaceContactFields({ phone, email, openingHours, onChange }: Pro const clearHours = () => { onChange('opening_hours', ''); setEditing(false) } return ( -
+
diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index 9861a63cb..8ed801975 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -55,6 +55,21 @@ beforeEach(() => { }); describe('PlaceFormModal', () => { + it('FE-COMP-PLACEFORM-2472: an empty search offers adding the place by hand and takes the query as the name', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'osm' }))); + render(); + const search = screen.getByPlaceholderText(/Search places/i); + await user.type(search, 'Tiny Garage Cafe'); + await user.click(screen.getByRole('button', { name: 'Search' })); + expect(await screen.findByText('Nothing found for “Tiny Garage Cafe”')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Add by hand' })); + expect(screen.queryByText('Nothing found for “Tiny Garage Cafe”')).not.toBeInTheDocument(); + // The search field keeps the query and the name field now holds it too. + expect(screen.getAllByDisplayValue('Tiny Garage Cafe')).toHaveLength(2); + expect(screen.getByLabelText('Phone')).toBeInTheDocument(); + }); + it('FE-COMP-PLACEFORM-001: renders modal when isOpen is true', () => { render(); expect(document.body).toBeInTheDocument(); diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index d2ccf5b71..58ce9bc24 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -12,7 +12,7 @@ import CollectionPicker from '../Collections/CollectionPicker' import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn' import { useToast } from '../shared/Toast' import { Tooltip } from '../shared/Tooltip' -import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed } from 'lucide-react' +import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react' import { useTranslation } from '../../i18n' import CustomTimePicker from '../shared/CustomTimePicker' import { PlaceContactFields } from './PlaceContactFields' @@ -164,6 +164,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) { return id != null ? String(id) : '' } const [pendingFiles, setPendingFiles] = useState([]) + // The query the last full search found nothing for (#2472): the cue to add the + // place by hand instead of a silent empty list. + const [emptySearch, setEmptySearch] = useState(null) /** * The leg of the drive the traveller picked, or empty while the projection's own * answer stands. Empty rather than seeded, because there is nothing to project onto @@ -451,6 +454,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' } setNearbyList(false) setMapsResults(result.places || []) + setEmptySearch((result.places || []).length === 0 ? trimmed : null) setSearchSource(result.source || '') } catch (err: unknown) { if (epoch !== searchEpochRef.current) return @@ -818,6 +822,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { setShowNewCategory, isSaving, pendingFiles, + emptySearch, setEmptySearch, fileRef, acSuggestions, setAcSuggestions, @@ -1037,7 +1042,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { // in from the map already has what the search would give. autoFocus={!place && !prefillCoords} value={mapsSearch} - onChange={e => setMapsSearch(e.target.value)} + onChange={e => { setMapsSearch(e.target.value); S.setEmptySearch(null) }} onKeyDown={handleSearchKeyDown} onBlur={() => setTimeout(() => setAcSuggestions([]), 150)} onFocus={() => { @@ -1103,6 +1108,30 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
+ {/* Nothing found (#2472): say so, and offer the hand-made way in, taking + the query as the name when there is none yet. */} + {S.emptySearch && mapsResults.length === 0 && !isSearchingMaps && ( +
+ + + + + {t('places.searchNothing', { query: S.emptySearch })} + {t('places.searchNothingHint')} + + +
+ )} + {/* Search results (populated after full search) */} {mapsResults.length > 0 && (
@@ -1285,6 +1314,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { {/* Phone, e-mail and the place's own hours, for what the search did not know or a place nobody has listed (#2472). */}