Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ajoute une recherche de lieu à la carte #983

Merged
merged 3 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ APP_BAC_IDF_CITIES_FILE=data/bac_idf/cities.csv
DATABASE_URL="postgresql://dialog:dialog@database:5432/dialog"
REDIS_URL="redis://redis:6379"
API_ADRESSE_BASE_URL=https://api-adresse.data.gouv.fr
APP_IGN_GEOCODER_BASE_URL=https://data.geopf.fr
MATOMO_ENABLED=false
###> BD TOPO ###
BDTOPO_DATABASE_URL=
Expand Down
1 change: 1 addition & 0 deletions assets/customElements/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ import './auto_form';
import './modal_trigger';
import './map';
import './map_form';
import './map_search_form';
17 changes: 16 additions & 1 deletion assets/customElements/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ class MapLibreMap {

const METROPOLITAN_FRANCE_CENTER = '[2.725, 47.16]';

class MapElement extends HTMLElement {
export class MapElement extends HTMLElement {
connectedCallback() {
const mapHeight = this.getAttribute('mapHeight') || '100%';
const mapMinHeight = this.getAttribute('mapMinHeight') || '600px';
Expand Down Expand Up @@ -310,6 +310,21 @@ class MapElement extends HTMLElement {

observer.observe(this, { attributes: true });
}

/**
* Center map on given coordinates
* @param {[number, number]} coordinates
* @param {number} zoom
*/
flyTo(coordinates, zoom) {
this.map?.flyTo({
center: coordinates,
zoom,
// Animation options
duration: 2000, // ms
essential: false, // Disable if browser has [prefers-reduced-motion]
});
}
}

customElements.define('d-map', MapElement);
25 changes: 25 additions & 0 deletions assets/customElements/map_search_form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// @ts-check

import { getAttributeOrError, querySelectorOrError } from "./util";
import { MapElement } from './map';

customElements.define('d-map-search-form', class extends HTMLElement {
connectedCallback() {
requestAnimationFrame(() => {
/** @type {MapElement} */
const map = querySelectorOrError(document, `#${getAttributeOrError(this, 'target')}`);

/** @type {HTMLInputElement} */
const searchValueField = querySelectorOrError(document, '#search_value');

searchValueField.addEventListener('change', () => {
const { coordinates, kind } = JSON.parse(searchValueField.value);

// Zoom closer on streets
const zoom = kind === 'street' ? 17 : 14;

map.flyTo(coordinates, zoom);
});
});
}
});
2 changes: 2 additions & 0 deletions config/packages/framework.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ framework:
base_uri: '%env(APP_EUDONET_PARIS_BASE_URL)%'
litteralis.wfs.http.client:
base_uri: '%env(APP_LITTERALIS_WFS_BASE_URL)%'
ign.geocoder.client:
base_uri: '%env(APP_IGN_GEOCODER_BASE_URL)%'

when@test:
framework:
Expand Down
1 change: 1 addition & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ security:
- { path: ^/admin, roles: ROLE_SUPER_ADMIN }
- { path: '^/regulations/([0-9a-f]{8}-[0-9a-f]{4}-[13-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$', methods: [GET], roles: PUBLIC_ACCESS }
- { path: '^/regulations$', methods: [GET], roles: PUBLIC_ACCESS }
- { path: ^/_fragment/map, roles: PUBLIC_ACCESS }
- { path: '^/(_fragment|regulations|feedback|mon-espace)', roles: ROLE_USER }
- { path: ^/, roles: PUBLIC_ACCESS }

Expand Down
3 changes: 3 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ when@test:
App\Tests\Mock\Litteralis\LitteralisMockHttpClient:
decorates: 'litteralis.wfs.http.client'
decoration_inner_name: 'App\Tests\Mock\Litteralis\LitteralisMockHttpClient::litteralis.wfs.http.client'
App\Tests\Mock\IgnGeocoderMockClient:
decorates: 'ign.geocoder.client'
decoration_inner_name: 'App\Tests\Mock\IgnGeocoderMockClient::ign.geocoder.client'
App\Infrastructure\Adapter\DateUtils:
class: App\Tests\Mock\DateUtilsMock
Psr\Log\NullLogger: ~
Expand Down
10 changes: 10 additions & 0 deletions src/Application/MapGeocoderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace App\Application;

interface MapGeocoderInterface
{
public function findPlaces(string $search): array;
}
47 changes: 47 additions & 0 deletions src/Infrastructure/Adapter/IgnMapGeocoder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace App\Infrastructure\Adapter;

use App\Application\MapGeocoderInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class IgnMapGeocoder implements MapGeocoderInterface
{
public function __construct(
private HttpClientInterface $ignGeocoderClient,
) {
}

public function findPlaces(string $search): array
{
$response = $this->ignGeocoderClient->request(
'GET',
'/geocodage/completion',
[
'query' => [
'text' => $search,
'type' => 'StreetAddress, PositionOfInterest',
'poiType' => 'administratif',
],
],
);

$data = json_decode($response->getContent(), true);

$places = [];

foreach ($data['results'] as $result) {
$places[] = [
'label' => $result['fulltext'],
'value' => [
'coordinates' => [$result['x'], $result['y']],
'kind' => $result['kind'],
],
];
}

return $places;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace App\Infrastructure\Controller\Map\Fragments;

use App\Application\MapGeocoderInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Annotation\Route;

final class MapSearchController
{
public function __construct(
private \Twig\Environment $twig,
private MapGeocoderInterface $mapGeocoder,
) {
}

#[Route(
'/_fragment/map/search',
name: 'fragment_carto_search',
methods: ['GET'],
)]
public function __invoke(
#[MapQueryParameter] string $search = '',
florimondmanca marked this conversation as resolved.
Show resolved Hide resolved
): Response {
$results = $search ? $this->mapGeocoder->findPlaces($search) : [];

return new Response(
$this->twig->render(
name: 'map/fragments/search_results.html.twig',
context: [
'results' => $results,
],
),
);
}
}
49 changes: 49 additions & 0 deletions templates/map/_search_form.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<div
class="fr-x-autocomplete-wrapper"
data-controller="autocomplete"
data-autocomplete-url-value="{{ path('fragment_carto_search') }}"
data-autocomplete-query-param-value="search"
data-autocomplete-min-length-value="3"
data-autocomplete-delay-value="500"
data-autocomplete-loading-status-value="{{ 'common.autocomplete.status.loading'|trans }}"
data-autocomplete-empty-status-value="{{ 'common.autocomplete.status.min_chars'|trans({ '%minChars%': 3 }) }}"
data-action="autocomplete.change->reset#reset"
>
<div class="fr-search-bar">
<label for="map_search" class="app-sr-only">
{{ 'map.search_form.search'|trans }}
</label>

<input
id="search_value"
name="search_value"
type="hidden"
data-autocomplete-target="hidden"
>

<input
id="search"
name="search"
type="text"
class="fr-input"
spellcheck="false"
autocomplete="off"
data-autocomplete-target="input"
placeholder="{{ 'map.search_form.search.placeholder'|trans }}"
>

<button class="fr-btn" aria-label="{{ 'common.form.search'|trans }}">
{{ 'common.form.search'|trans }}
</button>
</div>

<ul
id="map_search-results"
role="listbox"
aria-label="{{ 'map.search_form.results_label'|trans }}"
class="fr-x-autocomplete"
data-autocomplete-target="results"
>
<li role="status" data-autocomplete-target="status"></li>
</ul>
</div>
7 changes: 7 additions & 0 deletions templates/map/fragments/search_results.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% for result in results %}
<li role="option" data-autocomplete-value="{{ result.value|json_encode }}">{{ result.label }}</li>
{% endfor %}

<template id="status">
{{ 'common.autocomplete.results_count'|trans({ '%count%': results|length }) }}
</template>
7 changes: 6 additions & 1 deletion templates/map/map.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
<div class="fr-grid-row fr-x-h-full">
<div class="fr-col-12 fr-col-sm-4 fr-col-md-3 fr-container fr-pt-2w fr-pb-6w">
<h1 class="fr-h4">{{ 'map.title'|trans }} </h1>
<h2 class="fr-h6">{{ 'map.filters.title'|trans }}</h2>

<d-map-search-form target="map">
{% include 'map/_search_form.html.twig' with {} only %}
</d-map-search-form>

<h2 class="fr-h6 fr-mt-2w">{{ 'map.filters.title'|trans }}</h2>

<d-map-form target="map" urlAttribute="dataUrl">
{{ form_start(form, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace App\Tests\Integration\Infrastructure\Controller\Map\Fragment;

use App\Tests\Integration\Infrastructure\Controller\AbstractWebTestCase;

final class MapSearchControllerTest extends AbstractWebTestCase
{
public function testGetNoSearch(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/_fragment/map/search');

$this->assertResponseStatusCodeSame(200);
$this->assertSecurityHeaders();

$this->assertSame('Aucun résultat', $crawler->filter('#status')->innerText());

$items = $crawler->filter('li');
$this->assertCount(0, $items);
}

public function testGetEmpty(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/_fragment/map/search?search=EmptyPlease');

$this->assertResponseStatusCodeSame(200);
$this->assertSecurityHeaders();

$this->assertSame('Aucun résultat', $crawler->filter('#status')->innerText());

$items = $crawler->filter('li');
$this->assertCount(0, $items);
}

public function testGet(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/_fragment/map/search?search=Par');

$this->assertResponseStatusCodeSame(200);
$this->assertSecurityHeaders();

$this->assertSame('2 résultats', $crawler->filter('#status')->innerText());

$items = $crawler->filter('li');
$this->assertCount(2, $items);

$this->assertSame('Rue du Parc', $items->eq(0)->innerText());
$this->assertSame('{"coordinates":["x1","y1"],"kind":"street"}', $items->eq(0)->attr('data-autocomplete-value'));

$this->assertSame('Paris', $items->eq(1)->innerText());
$this->assertSame('{"coordinates":["x2","y2"],"kind":"administratif"}', $items->eq(1)->attr('data-autocomplete-value'));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,9 @@ public function testGet(): void
$this->assertResponseStatusCodeSame(200);
$this->assertSecurityHeaders();
$this->assertMetaTitle('Carte - DiaLog', $crawler);

// Search form is present
$this->assertNotNull($crawler->selectButton('Rechercher'));
$this->assertNotNull($crawler->filter('#search[name=search][autocomplete=off][spellcheck=false]')->first());
}
}
57 changes: 57 additions & 0 deletions tests/Mock/IgnGeocoderMockClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace App\Tests\Mock;

use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class IgnGeocoderMockClient extends MockHttpClient
{
private string $baseUri = 'https://testserver';
private array $requests;

public function __construct()
{
$callback = \Closure::fromCallable([$this, 'handleRequests']);
parent::__construct($callback, $this->baseUri);
}

private function handleRequests(string $method, string $url, array $options): MockResponse
{
$this->requests[] = ['url' => $url, 'options' => $options];

if (preg_match('/\/geocodage\/completion/', $url)) {
return new MockResponse($this->getGeocodageCompletionJSON($options['query']['text']), ['http_code' => 200]);
}

throw new \UnexpectedValueException("Mock not implemented: $method $url");
}

private function getGeocodageCompletionJSON(string $text): string
{
if ($text === 'Par') {
return json_encode([
'results' => [
[
'fulltext' => 'Rue du Parc',
'x' => 'x1',
'y' => 'y1',
'kind' => 'street',
],
[
'fulltext' => 'Paris',
'x' => 'x2',
'y' => 'y2',
'kind' => 'administratif',
],
],
]);
}

return json_encode([
'results' => [],
]);
}
}
Loading
Loading