feat: make spatial entry optional and authoritative
This commit is contained in:
@@ -762,6 +762,7 @@ interface MapWorkspaceProps {
|
||||
availableMapDatasets: DatasetCreateResponse[]
|
||||
selectedMapDatasetId: string
|
||||
onSelectMapArea: (areaId: string) => void
|
||||
onActivateMunicipality: (niscode: string) => Promise<AreaRead | null>
|
||||
onSetContextSourceLabel: (label: string | null) => void
|
||||
onSetContextLayerLabel: (label: string | null) => void
|
||||
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
|
||||
@@ -855,6 +856,7 @@ export function MapWorkspace({
|
||||
availableMapDatasets,
|
||||
selectedMapDatasetId,
|
||||
onSelectMapArea,
|
||||
onActivateMunicipality,
|
||||
onSetContextSourceLabel,
|
||||
onSetContextLayerLabel,
|
||||
onOpenDatasetInMap,
|
||||
@@ -2178,7 +2180,7 @@ export function MapWorkspace({
|
||||
<div>
|
||||
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
|
||||
<h2>Gebied analyseren</h2>
|
||||
<p>Kies een gemeente, selecteer een thema en analyseer het volledige gebied of een eigen rechthoek.</p>
|
||||
<p>Verken vrij op de kaart, gebruik optioneel een officiële grens en vraag daarna inzichten op voor uw selectie.</p>
|
||||
</div>
|
||||
<div className="geo-explorer-header-tools">
|
||||
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
|
||||
@@ -2225,10 +2227,10 @@ export function MapWorkspace({
|
||||
</header>
|
||||
|
||||
<MunicipalitySearch
|
||||
areas={areas}
|
||||
selectedAreaId={selectedMapAreaId}
|
||||
projectId={selectedProjectId}
|
||||
activeArea={selectedMapArea ?? null}
|
||||
disabled={workspaceLoading}
|
||||
onSelect={handleSelectMapArea}
|
||||
onActivate={onActivateMunicipality}
|
||||
/>
|
||||
|
||||
{workspaceLoading ? (
|
||||
@@ -2251,10 +2253,9 @@ export function MapWorkspace({
|
||||
<div className="geo-explorer-layout">
|
||||
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
|
||||
<div className="geo-panel-heading">
|
||||
<span>2</span>
|
||||
<div>
|
||||
<h3>Thema</h3>
|
||||
<p>Kies welke gegevens u wilt meten.</p>
|
||||
<h3>Focus op de kaart <small>optioneel</small></h3>
|
||||
<p>Dit bepaalt de zichtbare laag en hoofdmeting; Inzichten controleert ook de overige beschikbare thema’s.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
|
||||
@@ -2264,8 +2265,8 @@ export function MapWorkspace({
|
||||
{workspaceLoading
|
||||
? 'Gebieden en bronnen worden geladen'
|
||||
: municipalityAreaCount > 0
|
||||
? `${municipalityAreaCount} gemeenten en de volledige regio beschikbaar`
|
||||
: 'Geen gemeentelijke onderverdeling in deze werkruimte'}
|
||||
? `${municipalityAreaCount} geactiveerde gemeentegrenzen; vrij tekenen blijft mogelijk`
|
||||
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
|
||||
</small>
|
||||
</div>
|
||||
<div className="geo-theme-list">
|
||||
@@ -2517,9 +2518,8 @@ export function MapWorkspace({
|
||||
<div className="geo-map-stage">
|
||||
<div className="geo-map-toolbar" aria-label="Gebied selecteren">
|
||||
<div className="geo-panel-heading geo-map-step">
|
||||
<span>3</span>
|
||||
<div>
|
||||
<h3>Selecteer een gebied</h3>
|
||||
<h3>Baken uw onderzoeksvraag af</h3>
|
||||
<p>
|
||||
{bboxSelectionMode
|
||||
? 'Sleep nu een rechthoek op de kaart.'
|
||||
@@ -2640,10 +2640,9 @@ export function MapWorkspace({
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="geo-panel-heading">
|
||||
<span>3</span>
|
||||
<div>
|
||||
<h3>Inzichten</h3>
|
||||
<p>Alleen gemeten gegevens uit beschikbare bronnen.</p>
|
||||
<p>Gemeten resultaten en beschikbaarheid voor de volledige selectie.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { areasApi } from '../../services/api/areas'
|
||||
import { MunicipalitySearch } from './MunicipalitySearch'
|
||||
import type { AreaRead } from '../../types'
|
||||
|
||||
const areas = [
|
||||
{ id: 'mol', project_id: 'project', name: 'Gemeente Mol', geometry: { type: 'Polygon', coordinates: [] } },
|
||||
{ id: 'gent', project_id: 'project', name: 'Gemeente Gent', geometry: { type: 'Polygon', coordinates: [] } },
|
||||
{ id: 'flanders', project_id: 'project', name: 'Vlaanderen', geometry: { type: 'Polygon', coordinates: [] } },
|
||||
] satisfies AreaRead[]
|
||||
vi.mock('../../services/api/areas', () => ({
|
||||
areasApi: { searchMunicipalities: vi.fn() },
|
||||
}))
|
||||
|
||||
describe('MunicipalitySearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(areasApi.searchMunicipalities).mockResolvedValue({
|
||||
items: [{ niscode: '13025', name: 'Mol', name_nl: 'Mol', name_fr: 'Mol', name_de: 'Mol' }],
|
||||
total: 1,
|
||||
})
|
||||
})
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('selects an exact municipality while excluding regional areas', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<MunicipalitySearch areas={areas} selectedAreaId="" onSelect={onSelect} />)
|
||||
it('searches the official municipality catalog and activates a result', async () => {
|
||||
const activated = { id: 'mol', project_id: 'project', name: 'Gemeente Mol - NIS 13025' } as AreaRead
|
||||
const onActivate = vi.fn().mockResolvedValue(activated)
|
||||
render(<MunicipalitySearch projectId="project" activeArea={null} onActivate={onActivate} />)
|
||||
|
||||
const input = screen.getByTestId('municipality-search-input')
|
||||
fireEvent.change(input, { target: { value: 'Mol' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Gemeente laden' }))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('mol')
|
||||
expect(screen.getByText('2 gemeenten beschikbaar')).toBeTruthy()
|
||||
fireEvent.change(screen.getByTestId('municipality-search-input'), { target: { value: 'Mol' } })
|
||||
await waitFor(() => expect(areasApi.searchMunicipalities).toHaveBeenCalledWith('project', 'Mol'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Mol/ }))
|
||||
await waitFor(() => expect(onActivate).toHaveBeenCalledWith('13025'))
|
||||
})
|
||||
|
||||
it('shows the active municipality without the technical prefix', () => {
|
||||
render(<MunicipalitySearch areas={areas} selectedAreaId="gent" onSelect={vi.fn()} />)
|
||||
expect(screen.getByDisplayValue('Gent')).toBeTruthy()
|
||||
expect(screen.getByText('Gent actief')).toBeTruthy()
|
||||
it('presents municipality search as optional and keeps free selection visible', () => {
|
||||
render(<MunicipalitySearch projectId="project" activeArea={null} onActivate={vi.fn()} />)
|
||||
expect(screen.getByText('optioneel')).toBeTruthy()
|
||||
expect(screen.getByText('Vrije kaartselectie')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,53 +1,109 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||
import { MapPin, Search } from 'lucide-react'
|
||||
import type { AreaRead } from '../../types'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin, Search, X } from 'lucide-react'
|
||||
import { areasApi } from '../../services/api/areas'
|
||||
import type { AreaRead, MunicipalitySearchItem } from '../../types'
|
||||
|
||||
interface MunicipalitySearchProps {
|
||||
areas: AreaRead[]
|
||||
selectedAreaId: string
|
||||
projectId: string | null
|
||||
activeArea: AreaRead | null
|
||||
disabled?: boolean
|
||||
onSelect: (areaId: string) => void
|
||||
onActivate: (niscode: string) => Promise<AreaRead | null>
|
||||
}
|
||||
|
||||
function municipalityLabel(area: AreaRead): string {
|
||||
return area.name.replace(/^Gemeente\s+/i, '')
|
||||
function areaDisplayName(area: AreaRead | null): string | null {
|
||||
return area?.name.replace(/^Gemeente\s+/i, '').replace(/\s+-\s+NIS\s+\d+$/i, '') ?? null
|
||||
}
|
||||
|
||||
export function MunicipalitySearch({ areas, selectedAreaId, disabled = false, onSelect }: MunicipalitySearchProps): JSX.Element {
|
||||
const municipalities = useMemo(
|
||||
() => areas.filter((area) => /^Gemeente\s/i.test(area.name)).sort((a, b) => a.name.localeCompare(b.name, 'nl-BE')),
|
||||
[areas],
|
||||
)
|
||||
const selectedArea = municipalities.find((area) => area.id === selectedAreaId) ?? null
|
||||
const [query, setQuery] = useState(selectedArea ? municipalityLabel(selectedArea) : '')
|
||||
const exactMatch = municipalities.find(
|
||||
(area) => municipalityLabel(area).toLocaleLowerCase('nl-BE') === query.trim().toLocaleLowerCase('nl-BE'),
|
||||
)
|
||||
export function MunicipalitySearch({ projectId, activeArea, disabled = false, onActivate }: MunicipalitySearchProps): JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<MunicipalitySearchItem[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [activatingCode, setActivatingCode] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const searchSequence = useRef(0)
|
||||
const activeMunicipality = areaDisplayName(activeArea)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedArea) setQuery(municipalityLabel(selectedArea))
|
||||
}, [selectedArea])
|
||||
const normalized = query.trim()
|
||||
const requestId = ++searchSequence.current
|
||||
if (!projectId || normalized.length < 2) {
|
||||
setResults([])
|
||||
setSearching(false)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
setError(null)
|
||||
const timeout = window.setTimeout(() => {
|
||||
void areasApi.searchMunicipalities(projectId, normalized)
|
||||
.then((response) => {
|
||||
if (requestId === searchSequence.current) setResults(response.items)
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (requestId === searchSequence.current) setError(caught instanceof Error ? caught.message : 'Gemeenten zoeken is niet gelukt')
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === searchSequence.current) setSearching(false)
|
||||
})
|
||||
}, 220)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [projectId, query])
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (exactMatch) onSelect(exactMatch.id)
|
||||
const activate = async (municipality: MunicipalitySearchItem) => {
|
||||
setActivatingCode(municipality.niscode)
|
||||
setError(null)
|
||||
const area = await onActivate(municipality.niscode)
|
||||
if (area) {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
} else {
|
||||
setError('De officiële gemeentegrens kon niet worden geactiveerd.')
|
||||
}
|
||||
setActivatingCode(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="municipality-search" onSubmit={submit} aria-label="Gemeente zoeken">
|
||||
<section className="municipality-search" aria-label="Optioneel een gemeente zoeken">
|
||||
<div className="municipality-search-copy">
|
||||
<span className="municipality-step"><MapPin aria-hidden="true" /> Start hier</span>
|
||||
<div><strong>Kies een gemeente</strong><small>De grens en beschikbare gegevens worden meteen als werkgebied geladen.</small></div>
|
||||
<span className="municipality-shortcut"><MapPin aria-hidden="true" /> Snelkeuze</span>
|
||||
<div>
|
||||
<strong>Ga naar een gemeente <small>optioneel</small></strong>
|
||||
<p>Zoek een officiële grens, of teken straks vrij op de kaart.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
<span className="sr-only">Zoek gemeente</span><Search aria-hidden="true" />
|
||||
<input type="search" list="municipality-options" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Typ bijvoorbeeld Mol, Gent of Namen" disabled={disabled || municipalities.length === 0} autoComplete="off" data-testid="municipality-search-input" />
|
||||
</label>
|
||||
<datalist id="municipality-options">
|
||||
{municipalities.map((area) => <option key={area.id} value={municipalityLabel(area)} />)}
|
||||
</datalist>
|
||||
<button className="primary-action" type="submit" disabled={disabled || !exactMatch}>Gemeente laden</button>
|
||||
<span className="municipality-search-status" aria-live="polite">{selectedArea ? `${municipalityLabel(selectedArea)} actief` : `${municipalities.length} gemeenten beschikbaar`}</span>
|
||||
</form>
|
||||
<div className="municipality-search-control">
|
||||
<label>
|
||||
<span className="sr-only">Zoek op gemeentenaam of NIS-code</span>
|
||||
<Search aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Gemeentenaam of NIS-code"
|
||||
disabled={disabled || !projectId}
|
||||
autoComplete="off"
|
||||
data-testid="municipality-search-input"
|
||||
/>
|
||||
{query ? <button type="button" className="municipality-search-clear" onClick={() => setQuery('')} aria-label="Zoekterm wissen"><X aria-hidden="true" /></button> : null}
|
||||
</label>
|
||||
{query.trim().length >= 2 ? (
|
||||
<div className="municipality-search-results" aria-label="Gevonden gemeenten">
|
||||
{searching ? <p role="status">Gemeenten zoeken…</p> : null}
|
||||
{!searching && results.length === 0 && !error ? <p>Geen officiële gemeente gevonden.</p> : null}
|
||||
{results.map((municipality) => (
|
||||
<button key={municipality.niscode} type="button" onClick={() => void activate(municipality)} disabled={activatingCode !== null}>
|
||||
<span><strong>{municipality.name}</strong><small>NIS {municipality.niscode}</small></span>
|
||||
<em>{activatingCode === municipality.niscode ? 'Laden…' : 'Gebruik grens'}</em>
|
||||
</button>
|
||||
))}
|
||||
{error ? <p className="error" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="municipality-search-status">
|
||||
<span>Actief werkgebied</span>
|
||||
<strong>{activeMunicipality ?? (activeArea?.name || 'Vrije kaartselectie')}</strong>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user