feat(places): send a search to Google, once or always

Since 4.3.0 the TREK index and OpenStreetMap answer every search first and
Google is only asked when they find nothing, so a list with the wrong place
on it never reached Google, whatever key the instance had, and the provider
choice only decided who fills the empty case.

Two ways to change that, both only where Google holds the key slot. Under a
result list that did not come from Google, one quiet line sends the same
query to Google Places alone (`provider: 'google'` on the search contract,
the MCP search tool takes it too). And beside the key, an admin switch
"Search with Google only" hands every search and every suggestion to Google
and asks nothing of the index or OpenStreetMap; off by default, read by the
search after the keyed provider so an install without a key, or one on Amap
or OpenStreetMap, is untouched. The panel says so beside the switch.

Both shells share the one rule for when the line is offered (placeSource),
and the wiki names both switches.
This commit is contained in:
Maurice
2026-09-21 22:14:44 +02:00
parent 11b1399b5c
commit e12ae950df
70 changed files with 525 additions and 39 deletions
@@ -186,6 +186,53 @@ describe('PlaceFormModal', () => {
expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-018b: a list the index answered offers Google instead, and the link sends the same query there alone', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/maps/search', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
return body.provider === 'google'
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' })
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' });
}),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
const searchRow = searchInput.closest('.flex') as HTMLElement;
await user.click(within(searchRow).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
// The quiet line under the list, only because the instance has a key and
// this list did not come from Google.
const retry = screen.getByRole('button', { name: /Search Google instead/ });
await user.click(retry);
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
expect(bodies[0]).not.toHaveProperty('provider');
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
// A list Google produced has nowhere further to go.
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
});
it('FE-PLANNER-PLACEFORM-018c: without a Google key the list offers nothing', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }),
),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
});
it('FE-PLANNER-PLACEFORM-019: pressing Enter in search input triggers search', async () => {
const user = userEvent.setup();
server.use(
@@ -13,12 +13,12 @@ import { useSettingsStore } from '../../store/settingsStore'
import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
import { useToast } from '../shared/Toast'
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus } from 'lucide-react'
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isMapUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../utils/apiError'
import { sourceLabelFor } from '../../utils/placeSource'
import { offersGoogleRetry, sourceLabelFor } from '../../utils/placeSource'
import { useLocationBias } from '../../hooks/useLocationBias'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -396,7 +396,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
setForm(prev => ({ ...prev, [field]: value }))
}
const handleMapsSearch = async () => {
const handleMapsSearch = async (provider?: 'google') => {
if (!mapsSearch.trim()) return
setIsSearchingMaps(true)
try {
@@ -419,7 +419,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
return
}
}
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint, provider)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
setMapsResults(result.places || [])
setSearchSource(result.source || '')
@@ -1002,6 +1002,21 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
))}
</div>
)}
{/* The index answers first and Google only when it finds nothing, so a
list with the wrong place on it never reaches Google by itself. One
quiet line under the list sends the same query there, on an instance
that has a key and for a list Google did not already produce. */}
{mapsResults.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && (
<button
type="button"
onClick={() => handleMapsSearch('google')}
disabled={isSearchingMaps}
className="mt-1.5 inline-flex items-center gap-1 text-caption text-content-faint hover:text-content transition-colors disabled:opacity-50"
>
<RotateCcw size={11} strokeWidth={2} aria-hidden="true" />
{t('places.searchGoogleInstead')}
</button>
)}
</div>
{/* Name */}