mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -671,6 +671,8 @@ export const adminApi = {
|
||||
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
||||
getPlacesGoogleOnly: () => apiClient.get('/admin/places-google-only').then(r => r.data),
|
||||
updatePlacesGoogleOnly: (enabled: boolean) => apiClient.put('/admin/places-google-only', { enabled }).then(r => r.data),
|
||||
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
|
||||
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
|
||||
getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data),
|
||||
@@ -1215,13 +1217,18 @@ export const mapsApi = {
|
||||
* than interleaved: the core list is ordered by relevance and has earned that order,
|
||||
* and a plugin's row carries its own `source` for a caller that wants to mark it.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
|
||||
/**
|
||||
* `provider: 'google'` sends this one search to Google alone, the "search Google
|
||||
* instead" link under a list the index answered with the wrong place. The
|
||||
* server ignores it on an instance without a Google key.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') =>
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
|
||||
// Side by side, so the wait is the slower of the two rather than their sum. Only
|
||||
// the core call may reject: that rejection is what hands withCachedPlaces the
|
||||
// offline path, and a plugin failure must never trigger it.
|
||||
const [core, extra] = await Promise.all([
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias, ...(provider ? { provider } : {}) }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
pluginSearchPlaces(query, lang, locationBias),
|
||||
])
|
||||
if (extra.length === 0) return core
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
|
||||
placesDetailsEnabled, setPlacesDetailsEnabledState,
|
||||
placesEnrichEnabled, setPlacesEnrichEnabledState,
|
||||
placesGoogleOnly, handleTogglePlacesGoogleOnly,
|
||||
placeShadowEnabled, setPlaceShadowEnabledState,
|
||||
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
|
||||
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
||||
@@ -350,6 +351,17 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MAdminRow
|
||||
title={t('admin.placesGoogleOnly.title')}
|
||||
hint={t(hasMapsKey ? 'admin.placesGoogleOnly.subtitle' : 'admin.placesGoogleOnly.missingKey')}
|
||||
trailing={
|
||||
<MToggle
|
||||
checked={placesGoogleOnly}
|
||||
ariaLabel={t('admin.placesGoogleOnly.title')}
|
||||
onChange={() => handleTogglePlacesGoogleOnly()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</MBlockDisclosure>
|
||||
</MProviderBlock>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Loader2, Search } from 'lucide-react'
|
||||
import { Loader2, RotateCcw, Search } from 'lucide-react'
|
||||
import { mapsApi } from '../../../../api/client'
|
||||
import { sourceLabelFor } from '../../../../utils/placeSource'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
import { offersGoogleRetry, sourceLabelFor } from '../../../../utils/placeSource'
|
||||
import { recordPlacePick } from '../../../../api/placeShadow'
|
||||
import { PlacesSession } from '../../../../utils/placesSession'
|
||||
import { isMapUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
|
||||
@@ -95,6 +96,9 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
// The name the whole list carries, for rows that do not name their own index.
|
||||
const [acSource, setAcSource] = useState('')
|
||||
// What answered the last full search, for the line that offers Google instead.
|
||||
const [searchSource, setSearchSource] = useState('')
|
||||
const hasMapsKey = useAuthStore(s => s.hasMapsKey)
|
||||
|
||||
const setResolving = useCallback(
|
||||
(v: boolean) => {
|
||||
@@ -168,7 +172,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
const handleSearch = async (provider?: 'google') => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return
|
||||
setSuggestions([])
|
||||
@@ -200,9 +204,10 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
}
|
||||
// Derselbe Hinweis, den die Vervollstaendigung schon bekommt: die Suche
|
||||
// braucht ihn genauso, nur als Punkt statt als Kasten.
|
||||
const result = await mapsApi.search(trimmed, language, pointFromBox(locationBias))
|
||||
const result = await mapsApi.search(trimmed, language, pointFromBox(locationBias), provider)
|
||||
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
|
||||
setResults(result.places || [])
|
||||
setSearchSource(result.source || '')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
@@ -284,7 +289,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearch}
|
||||
onClick={() => handleSearch()}
|
||||
disabled={searching}
|
||||
aria-label={t('common.search')}
|
||||
className="flex h-10 w-10 flex-none items-center justify-center rounded-[12px] bg-m-act text-m-actfg disabled:opacity-60"
|
||||
@@ -332,6 +337,20 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* The same quiet line the desktop form has: the index answers first and
|
||||
Google only when it finds nothing, so this is how a list with the wrong
|
||||
place on it reaches Google. */}
|
||||
{results.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSearch('google')}
|
||||
disabled={searching}
|
||||
className="mt-2 inline-flex items-center gap-1 font-geist text-[0.6875rem] text-m-muted disabled:opacity-60"
|
||||
>
|
||||
<RotateCcw size={11} strokeWidth={2} aria-hidden="true" />
|
||||
{t('places.searchGoogleInstead')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -435,6 +435,20 @@ describe('AdminSettingsTab', () => {
|
||||
expect(toggleFor('Place Enrichment')).toHaveAttribute('aria-label', 'Place Enrichment');
|
||||
});
|
||||
|
||||
it('FE-ADMSET-030d: the Google-only switch sits with the Google options, says what it needs without a key, and toggles through the hook', () => {
|
||||
const admin = renderTab({ placesGoogleOnly: false, hasMapsKey: false });
|
||||
expect(screen.getByText(/Needs a Google Maps API key/)).toBeInTheDocument();
|
||||
expect(toggleFor('Search with Google only')).toHaveAttribute('aria-pressed', 'false');
|
||||
fireEvent.click(toggleFor('Search with Google only'));
|
||||
expect(admin.handleTogglePlacesGoogleOnly).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-ADMSET-030e: with a key the Google-only row explains what it changes', () => {
|
||||
renderTab({ placesGoogleOnly: true, hasMapsKey: true });
|
||||
expect(screen.getByText(/Every search and every suggestion goes to Google Places/)).toBeInTheDocument();
|
||||
expect(toggleFor('Search with Google only')).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-ADMSET-031: the API keys card leads with the TREK index and no weather panel', () => {
|
||||
renderTab();
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
|
||||
placesDetailsEnabled, setPlacesDetailsEnabledState,
|
||||
placesEnrichEnabled, setPlacesEnrichEnabledState,
|
||||
placesGoogleOnly, handleTogglePlacesGoogleOnly,
|
||||
transitProvider, setTransitProviderState,
|
||||
transitGoogleKeySource, setTransitGoogleKeySource,
|
||||
placeShadowEnabled, setPlaceShadowEnabledState,
|
||||
@@ -448,8 +449,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
<GoogleOptions
|
||||
title={t('admin.googleOptions')}
|
||||
summary={t('admin.googleOptionsSummary', {
|
||||
on: [placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled].filter(Boolean).length,
|
||||
total: 4,
|
||||
on: [placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled, placesGoogleOnly].filter(Boolean).length,
|
||||
total: 5,
|
||||
})}
|
||||
>
|
||||
<div className="divide-y divide-edge-faint">
|
||||
@@ -520,6 +521,21 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The one row here that is about where a search goes rather than
|
||||
what the key may be spent on. Without a key it is a promise the
|
||||
search cannot keep, and the subtitle says so. */}
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-content-secondary">{t('admin.placesGoogleOnly.title')}</p>
|
||||
<p className="text-xs text-content-faint mt-0.5">{t(hasMapsKey ? 'admin.placesGoogleOnly.subtitle' : 'admin.placesGoogleOnly.missingKey')}</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
on={placesGoogleOnly}
|
||||
label={t('admin.placesGoogleOnly.title')}
|
||||
onToggle={handleTogglePlacesGoogleOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GoogleOptions>
|
||||
</ProviderBlock>
|
||||
|
||||
@@ -105,6 +105,16 @@ export function useAdmin() {
|
||||
useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, [])
|
||||
useEffect(() => { adminApi.getPlacesEnrich().then(d => setPlacesEnrichEnabledState(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// Search and suggestions from Google alone. Admin-only state: the search itself
|
||||
// reads the switch on the server, so nothing else in the client needs it.
|
||||
const [placesGoogleOnly, setPlacesGoogleOnlyState] = useState<boolean>(false)
|
||||
useEffect(() => { adminApi.getPlacesGoogleOnly().then(d => setPlacesGoogleOnlyState(d.enabled)).catch(() => {}) }, [])
|
||||
const handleTogglePlacesGoogleOnly = async () => {
|
||||
const next = !placesGoogleOnly
|
||||
setPlacesGoogleOnlyState(next)
|
||||
try { await adminApi.updatePlacesGoogleOnly(next) } catch { setPlacesGoogleOnlyState(!next) }
|
||||
}
|
||||
|
||||
// Transit backend (#1699). googleKeySource says where a Google key would come
|
||||
// from for this admin — null means picking Google changes nothing, since the
|
||||
// request-time fallback to Transitous is silent by design.
|
||||
@@ -481,6 +491,7 @@ export function useAdmin() {
|
||||
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
|
||||
placesDetailsEnabled, setPlacesDetailsEnabledState,
|
||||
placesEnrichEnabled, setPlacesEnrichEnabledState,
|
||||
placesGoogleOnly, handleTogglePlacesGoogleOnly,
|
||||
transitProvider, setTransitProviderState,
|
||||
transitGoogleKeySource, setTransitGoogleKeySource,
|
||||
placeShadowEnabled, setPlaceShadowEnabledState,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { offersGoogleRetry, sourceLabelFor } from './placeSource'
|
||||
|
||||
describe('offersGoogleRetry', () => {
|
||||
it('FE-PLACESOURCE-001: offers Google for a list the index or OpenStreetMap answered, only where a key exists', () => {
|
||||
expect(offersGoogleRetry('trek-places+openstreetmap', true)).toBe(true)
|
||||
expect(offersGoogleRetry('openstreetmap', true)).toBe(true)
|
||||
expect(offersGoogleRetry('trek-places+plugin', true)).toBe(true)
|
||||
expect(offersGoogleRetry('trek-places+openstreetmap', false)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-PLACESOURCE-002: a list Google produced, the offline cache and no list at all have nowhere further to go', () => {
|
||||
expect(offersGoogleRetry('google', true)).toBe(false)
|
||||
expect(offersGoogleRetry('google+plugin', true)).toBe(false)
|
||||
expect(offersGoogleRetry('offline-cache', true)).toBe(false)
|
||||
expect(offersGoogleRetry('', true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sourceLabelFor', () => {
|
||||
it('FE-PLACESOURCE-003: a row names its own index, else the list it came from', () => {
|
||||
const t = (k: string) => k
|
||||
expect(sourceLabelFor({ source: 'openstreetmap' }, 'trek-places+openstreetmap', t)).toBe('OpenStreetMap')
|
||||
expect(sourceLabelFor({}, 'google', t)).toBe('Google')
|
||||
expect(sourceLabelFor({}, 'amap', t)).toBe('places.source.amap')
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,21 @@ const SOURCE_KEYS: Record<string, string> = {
|
||||
* answered the call: Google never marks its places, and a merged list marks only
|
||||
* the index side, so an unmarked row in one is OpenStreetMap by elimination.
|
||||
*/
|
||||
/**
|
||||
* Whether a result list can be sent to Google instead.
|
||||
*
|
||||
* The index and OpenStreetMap answer first, and Google is only asked when they
|
||||
* find nothing, so a list that has the wrong place on it never reaches Google
|
||||
* on its own. The link that sends it there is offered when the instance has a
|
||||
* Google key and this list did not already come from Google: a list Google
|
||||
* produced, alone or as the empty-case fallback, has nowhere further to go, and
|
||||
* the offline cache is not a search at all.
|
||||
*/
|
||||
export function offersGoogleRetry(listSource: string, hasMapsKey: boolean): boolean {
|
||||
if (!hasMapsKey || !listSource) return false
|
||||
return !listSource.split('+').includes('google') && listSource !== 'offline-cache'
|
||||
}
|
||||
|
||||
export function sourceLabelFor(place: unknown, listSource: string, t: TranslationFn): string | null {
|
||||
const own = (place as { source?: unknown } | null)?.source
|
||||
const source = typeof own === 'string' && own
|
||||
|
||||
@@ -76,6 +76,8 @@ export function buildAdminHook(overrides: Record<string, unknown> = {}): AdminHo
|
||||
placesDetailsEnabled: false,
|
||||
setPlacesDetailsEnabledState: vi.fn(),
|
||||
placesEnrichEnabled: true,
|
||||
placesGoogleOnly: false,
|
||||
handleTogglePlacesGoogleOnly: vi.fn(),
|
||||
setPlacesEnrichEnabledState: vi.fn(),
|
||||
// Fail-open on the server, so the harness starts where an unset row lands.
|
||||
trekPlacesEnabled: true,
|
||||
|
||||
Reference in New Issue
Block a user