From aef37d501abb9837f483135ea72a767d9c40596b Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Mon, 10 Jun 2024 23:06:54 +0200 Subject: [PATCH] [Map] Create Map component --- .gitattributes | 8 + .gitignore | 3 + .symfony.bundle.yaml | 3 + CHANGELOG.md | 5 + LICENSE | 19 ++ README.md | 16 ++ assets/dist/abstract_map_controller.d.ts | 55 +++++ assets/dist/abstract_map_controller.js | 47 +++++ assets/package.json | 21 ++ assets/src/abstract_map_controller.ts | 121 +++++++++++ assets/test/abstract_map_controller.test.ts | 76 +++++++ composer.json | 51 +++++ config/services.php | 45 ++++ doc/index.rst | 207 +++++++++++++++++++ phpunit.xml.dist | 26 +++ src/Exception/Exception.php | 19 ++ src/Exception/IncompleteDsnException.php | 19 ++ src/Exception/InvalidArgumentException.php | 19 ++ src/Exception/LogicException.php | 19 ++ src/Exception/RuntimeException.php | 19 ++ src/Exception/UnsupportedSchemeException.php | 38 ++++ src/InfoWindow.php | 40 ++++ src/Map.php | 104 ++++++++++ src/MapOptionsInterface.php | 23 +++ src/Marker.php | 36 ++++ src/Point.php | 46 +++++ src/Renderer/AbstractRenderer.php | 65 ++++++ src/Renderer/AbstractRendererFactory.php | 41 ++++ src/Renderer/Dsn.php | 81 ++++++++ src/Renderer/NullRenderer.php | 43 ++++ src/Renderer/NullRendererFactory.php | 39 ++++ src/Renderer/Renderer.php | 56 +++++ src/Renderer/RendererFactoryInterface.php | 22 ++ src/Renderer/RendererInterface.php | 25 +++ src/Renderer/Renderers.php | 64 ++++++ src/Test/RendererFactoryTestCase.php | 92 +++++++++ src/Test/RendererTestCase.php | 35 ++++ src/Twig/MapExtension.php | 29 +++ src/UXMapBundle.php | 109 ++++++++++ tests/InfoWindowTest.php | 41 ++++ tests/Kernel/AppKernelTrait.php | 41 ++++ tests/Kernel/FrameworkAppKernel.php | 44 ++++ tests/Kernel/TwigAppKernel.php | 46 +++++ tests/MapTest.php | 121 +++++++++++ tests/MarkerTest.php | 55 +++++ tests/PointTest.php | 45 ++++ tests/Renderer/DsnTest.php | 103 +++++++++ tests/Renderer/NullRendererFactoryTest.php | 47 +++++ tests/Renderer/NullRendererTest.php | 54 +++++ tests/Renderer/RendererTest.php | 46 +++++ tests/Renderer/RenderersTest.php | 71 +++++++ tests/TwigTest.php | 57 +++++ tests/UXMapBundleTest.php | 82 ++++++++ 53 files changed, 2639 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .symfony.bundle.yaml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/dist/abstract_map_controller.d.ts create mode 100644 assets/dist/abstract_map_controller.js create mode 100644 assets/package.json create mode 100644 assets/src/abstract_map_controller.ts create mode 100644 assets/test/abstract_map_controller.test.ts create mode 100644 composer.json create mode 100644 config/services.php create mode 100644 doc/index.rst create mode 100644 phpunit.xml.dist create mode 100644 src/Exception/Exception.php create mode 100644 src/Exception/IncompleteDsnException.php create mode 100644 src/Exception/InvalidArgumentException.php create mode 100644 src/Exception/LogicException.php create mode 100644 src/Exception/RuntimeException.php create mode 100644 src/Exception/UnsupportedSchemeException.php create mode 100644 src/InfoWindow.php create mode 100644 src/Map.php create mode 100644 src/MapOptionsInterface.php create mode 100644 src/Marker.php create mode 100644 src/Point.php create mode 100644 src/Renderer/AbstractRenderer.php create mode 100644 src/Renderer/AbstractRendererFactory.php create mode 100644 src/Renderer/Dsn.php create mode 100644 src/Renderer/NullRenderer.php create mode 100644 src/Renderer/NullRendererFactory.php create mode 100644 src/Renderer/Renderer.php create mode 100644 src/Renderer/RendererFactoryInterface.php create mode 100644 src/Renderer/RendererInterface.php create mode 100644 src/Renderer/Renderers.php create mode 100644 src/Test/RendererFactoryTestCase.php create mode 100644 src/Test/RendererTestCase.php create mode 100644 src/Twig/MapExtension.php create mode 100644 src/UXMapBundle.php create mode 100644 tests/InfoWindowTest.php create mode 100644 tests/Kernel/AppKernelTrait.php create mode 100644 tests/Kernel/FrameworkAppKernel.php create mode 100644 tests/Kernel/TwigAppKernel.php create mode 100644 tests/MapTest.php create mode 100644 tests/MarkerTest.php create mode 100644 tests/PointTest.php create mode 100644 tests/Renderer/DsnTest.php create mode 100644 tests/Renderer/NullRendererFactoryTest.php create mode 100644 tests/Renderer/NullRendererTest.php create mode 100644 tests/Renderer/RendererTest.php create mode 100644 tests/Renderer/RenderersTest.php create mode 100644 tests/TwigTest.php create mode 100644 tests/UXMapBundleTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..35c1f46 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/.symfony.bundle.yaml export-ignore +/phpunit.xml.dist export-ignore +/assets/src export-ignore +/assets/test export-ignore +/assets/vitest.config.js export-ignore +/tests export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50b321e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor +composer.lock +.phpunit.result.cache diff --git a/.symfony.bundle.yaml b/.symfony.bundle.yaml new file mode 100644 index 0000000..6d9a74a --- /dev/null +++ b/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "doc" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9603bd3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## Unreleased + +- Component added diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e374a5c --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d554e41 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Symfony UX Map + +**EXPERIMENTAL** This component is currently experimental and is +likely to change, or even change drastically. + +Symfony UX Map integrates interactive Maps in Symfony applications, like Leaflet or Google Maps. + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ux to create issues or submit pull requests. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-map/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/assets/dist/abstract_map_controller.d.ts b/assets/dist/abstract_map_controller.d.ts new file mode 100644 index 0000000..7e78dc6 --- /dev/null +++ b/assets/dist/abstract_map_controller.d.ts @@ -0,0 +1,55 @@ +import { Controller } from '@hotwired/stimulus'; +export type Point = { + lat: number; + lng: number; +}; +export type MapView = { + center: Point; + zoom: number; + fitBoundsToMarkers: boolean; + markers: Array>; + options: Options; +}; +export type MarkerDefinition = { + position: Point; + title: string | null; + infoWindow?: Omit, 'position'>; + rawOptions?: MarkerOptions; +}; +export type InfoWindowDefinition = { + headerContent: string | null; + content: string | null; + position: Point; + opened: boolean; + autoClose: boolean; + rawOptions?: InfoWindowOptions; +}; +export default abstract class extends Controller { + static values: { + providerOptions: ObjectConstructor; + view: ObjectConstructor; + }; + viewValue: MapView; + protected map: Map; + protected markers: Array; + protected infoWindows: Array; + initialize(): void; + connect(): void; + protected abstract doCreateMap({ center, zoom, options, }: { + center: Point; + zoom: number; + options: MapOptions; + }): Map; + createMarker(definition: MarkerDefinition): Marker; + protected abstract doCreateMarker(definition: MarkerDefinition): Marker; + protected createInfoWindow({ definition, marker, }: { + definition: MarkerDefinition['infoWindow']; + marker: Marker; + }): InfoWindow; + protected abstract doCreateInfoWindow({ definition, marker, }: { + definition: MarkerDefinition['infoWindow']; + marker: Marker; + }): InfoWindow; + protected abstract doFitBoundsToMarkers(): void; + private dispatchEvent; +} diff --git a/assets/dist/abstract_map_controller.js b/assets/dist/abstract_map_controller.js new file mode 100644 index 0000000..324a29c --- /dev/null +++ b/assets/dist/abstract_map_controller.js @@ -0,0 +1,47 @@ +import { Controller } from '@hotwired/stimulus'; + +class default_1 extends Controller { + constructor() { + super(...arguments); + this.markers = []; + this.infoWindows = []; + } + initialize() { } + connect() { + const { center, zoom, options, markers, fitBoundsToMarkers } = this.viewValue; + this.dispatchEvent('pre-connect', { options }); + this.map = this.doCreateMap({ center, zoom, options }); + markers.forEach((marker) => this.createMarker(marker)); + if (fitBoundsToMarkers) { + this.doFitBoundsToMarkers(); + } + this.dispatchEvent('connect', { + map: this.map, + markers: this.markers, + infoWindows: this.infoWindows, + }); + } + createMarker(definition) { + this.dispatchEvent('marker:before-create', { definition }); + const marker = this.doCreateMarker(definition); + this.dispatchEvent('marker:after-create', { marker }); + this.markers.push(marker); + return marker; + } + createInfoWindow({ definition, marker, }) { + this.dispatchEvent('info-window:before-create', { definition, marker }); + const infoWindow = this.doCreateInfoWindow({ definition, marker }); + this.dispatchEvent('info-window:after-create', { infoWindow, marker }); + this.infoWindows.push(infoWindow); + return infoWindow; + } + dispatchEvent(name, payload = {}) { + this.dispatch(name, { prefix: 'ux:map', detail: payload }); + } +} +default_1.values = { + providerOptions: Object, + view: Object, +}; + +export { default_1 as default }; diff --git a/assets/package.json b/assets/package.json new file mode 100644 index 0000000..2561ef5 --- /dev/null +++ b/assets/package.json @@ -0,0 +1,21 @@ +{ + "name": "@symfony/ux-map", + "description": "Integrates interactive maps in your Symfony applications", + "license": "MIT", + "version": "1.0.0", + "type": "module", + "main": "dist/abstract_map_controller.js", + "types": "dist/abstract_map_controller.d.ts", + "symfony": { + "importmap": { + "@hotwired/stimulus": "^3.0.0", + "@symfony/ux-map/abstract-map-controller": "path:%PACKAGE%/dist/abstract_map_controller.js" + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0" + }, + "devDependencies": { + "@hotwired/stimulus": "^3.0.0" + } +} diff --git a/assets/src/abstract_map_controller.ts b/assets/src/abstract_map_controller.ts new file mode 100644 index 0000000..7b64d87 --- /dev/null +++ b/assets/src/abstract_map_controller.ts @@ -0,0 +1,121 @@ +import { Controller } from '@hotwired/stimulus'; + +export type Point = { lat: number; lng: number }; + +export type MapView = { + center: Point; + zoom: number; + fitBoundsToMarkers: boolean; + markers: Array>; + options: Options; +}; + +export type MarkerDefinition = { + position: Point; + title: string | null; + infoWindow?: Omit, 'position'>; + rawOptions?: MarkerOptions; +}; + +export type InfoWindowDefinition = { + headerContent: string | null; + content: string | null; + position: Point; + opened: boolean; + autoClose: boolean; + rawOptions?: InfoWindowOptions; +}; + +export default abstract class< + MapOptions, + Map, + MarkerOptions, + Marker, + InfoWindowOptions, + InfoWindow, +> extends Controller { + static values = { + providerOptions: Object, + view: Object, + }; + + declare viewValue: MapView; + + protected map: Map; + protected markers: Array = []; + protected infoWindows: Array = []; + + initialize() {} + + connect() { + const { center, zoom, options, markers, fitBoundsToMarkers } = this.viewValue; + + this.dispatchEvent('pre-connect', { options }); + + this.map = this.doCreateMap({ center, zoom, options }); + + markers.forEach((marker) => this.createMarker(marker)); + + if (fitBoundsToMarkers) { + this.doFitBoundsToMarkers(); + } + + this.dispatchEvent('connect', { + map: this.map, + markers: this.markers, + infoWindows: this.infoWindows, + }); + } + + protected abstract doCreateMap({ + center, + zoom, + options, + }: { + center: Point; + zoom: number; + options: MapOptions; + }): Map; + + public createMarker(definition: MarkerDefinition): Marker { + this.dispatchEvent('marker:before-create', { definition }); + const marker = this.doCreateMarker(definition); + this.dispatchEvent('marker:after-create', { marker }); + + this.markers.push(marker); + + return marker; + } + + protected abstract doCreateMarker(definition: MarkerDefinition): Marker; + + protected createInfoWindow({ + definition, + marker, + }: { + definition: MarkerDefinition['infoWindow']; + marker: Marker; + }): InfoWindow { + this.dispatchEvent('info-window:before-create', { definition, marker }); + const infoWindow = this.doCreateInfoWindow({ definition, marker }); + this.dispatchEvent('info-window:after-create', { infoWindow, marker }); + + this.infoWindows.push(infoWindow); + + return infoWindow; + } + + protected abstract doCreateInfoWindow({ + definition, + marker, + }: { + definition: MarkerDefinition['infoWindow']; + marker: Marker; + }): InfoWindow; + + protected abstract doFitBoundsToMarkers(): void; + + private dispatchEvent(name: string, payload: Record = {}): void { + this.dispatch(name, { prefix: 'ux:map', detail: payload }); + } +} diff --git a/assets/test/abstract_map_controller.test.ts b/assets/test/abstract_map_controller.test.ts new file mode 100644 index 0000000..1c17477 --- /dev/null +++ b/assets/test/abstract_map_controller.test.ts @@ -0,0 +1,76 @@ +import { Application } from '@hotwired/stimulus'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import AbstractMapController from '../src/abstract_map_controller.ts'; + +class MyMapController extends AbstractMapController { + doCreateMap({ center, zoom, options }) { + return { map: 'map', center, zoom, options }; + } + + doCreateMarker(definition) { + const marker = { marker: 'marker', title: definition.title }; + + if (definition.infoWindow) { + this.createInfoWindow({ definition: definition.infoWindow, marker }); + } + + return marker; + } + + doCreateInfoWindow({ definition, marker }) { + return { infoWindow: 'infoWindow', headerContent: definition.headerContent, marker: marker.title }; + } + + doFitBoundsToMarkers() { + // no-op + } +} + +const startStimulus = () => { + const application = Application.start(); + application.register('map', MyMapController); + return application; +}; + +describe('AbstractMapController', () => { + let container: HTMLElement; + + beforeEach(() => { + container = mountDOM(` +
+ `); + }); + + afterEach(() => { + clearDOM(); + }); + + it('connect and create map, marker and info window', async () => { + const div = getByTestId(container, 'map'); + expect(div).not.toHaveClass('connected'); + + const application = startStimulus(); + await waitFor(() => expect(application.getControllerForElementAndIdentifier(div, 'map')).not.toBeNull()); + + const controller = application.getControllerForElementAndIdentifier(div, 'map'); + expect(controller.map).toEqual({ map: 'map', center: { lat: 48.8566, lng: 2.3522 }, zoom: 4, options: {} }); + expect(controller.markers).toEqual([ + { marker: 'marker', title: 'Paris' }, + { marker: 'marker', title: 'Lyon' }, + ]); + expect(controller.infoWindows).toEqual([ + { + headerContent: 'Lyon', + infoWindow: 'infoWindow', + marker: 'Lyon', + }, + ]); + }); +}); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..73d3930 --- /dev/null +++ b/composer.json @@ -0,0 +1,51 @@ +{ + "name": "symfony/ux-map", + "type": "symfony-bundle", + "description": "Easily embed interactive maps in your Symfony application", + "keywords": [ + "symfony-ux", + "map", + "markers", + "maps" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Hugo Alliaume", + "email": "hugo@alliau.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\Map\\": "src/" + }, + "exclude-from-classmap": [] + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\Map\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.3", + "symfony/stimulus-bundle": "^2.18.1" + }, + "require-dev": { + "symfony/asset-mapper": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/twig-bundle": "^6.4|^7.0" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..7dada5e --- /dev/null +++ b/config/services.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\UX\Map\Renderer\AbstractRendererFactory; +use Symfony\UX\Map\Renderer\Renderer; +use Symfony\UX\Map\Renderer\Renderers; +use Symfony\UX\Map\Twig\MapExtension; + +/* + * @author Hugo Alliaume + */ +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('ux_map.renderers', Renderers::class) + ->factory([service('ux_map.renderer_factory'), 'fromStrings']) + ->args([ + abstract_arg('renderers configuration'), + ]) + ->tag('twig.runtime') + + ->set('ux_map.renderer_factory.abstract', AbstractRendererFactory::class) + ->abstract() + ->args([ + service('stimulus.helper'), + ]) + + ->set('ux_map.renderer_factory', Renderer::class) + ->args([ + tagged_iterator('ux_map.renderer_factory'), + ]) + + ->set('ux_map.twig_extension', MapExtension::class) + ->tag('twig.extension') + ; +}; diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 0000000..9ce8759 --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,207 @@ +Symfony UX Map +============== + +**EXPERIMENTAL** This component is currently experimental and is likely +to change, or even change drastically. + +Symfony UX Map is a Symfony bundle integrating interactive Maps in +Symfony applications. It is part of `the Symfony UX initiative`_. + +Installation +------------ + +.. caution:: + + Before you start, make sure you have `StimulusBundle configured in your app`_. + +Install the bundle using Composer and Symfony Flex: + +.. code-block:: terminal + + $ composer require symfony/ux-map + +If you're using WebpackEncore, install your assets and restart Encore (not +needed if you're using AssetMapper): + +.. code-block:: terminal + + $ npm install --force + $ npm run watch + +Configuration +------------- + +Configuration is done in your ``config/packages/ux_map.yaml`` file: + +.. code-block:: yaml + + # config/packages/ux_map.yaml + ux_map: + renderer: '%env(UX_MAP_DSN)%' + +The ``UX_MAP_DSN`` environment variable configure which renderer to use. + +Available renderers +~~~~~~~~~~~~~~~~~~~ + +========== =============================================================== +Renderer +========== =============================================================== +`Google`_ **Install**: ``composer require symfony/ux-map-google`` \ + **DSN**: ``UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default`` \ +`Leaflet`_ **Install**: ``composer require symfony/ux-map-leaflet`` \ + **DSN**: ``UX_MAP_DSN=leaflet://default`` \ +========== =============================================================== + +Usage +----- + +Creating and rendering +~~~~~~~~~~~~~~~~~~~~~~ + +A map is created by calling ``new Map()``. You can configure the center, zoom, and add markers:: + + namespace App\Controller; + + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + use Symfony\Component\HttpFoundation\Response; + use Symfony\Component\Routing\Attribute\Route; + use Symfony\UX\Map\InfoWindow; + use Symfony\UX\Map\Map; + use Symfony\UX\Map\Marker; + use Symfony\UX\Map\Point; + + final class HomeController extends AbstractController + { + #[Route('/')] + public function __invoke(): Response + { + // 1. Create a new map instance + $myMap = (new Map()); + ->center(new Point(46.903354, 1.888334)) + ->zoom(6) + ; + + // 2. You can add markers, with an optional info window + $myMap + ->addMarker(new Marker( + position: new Point(48.8566, 2.3522), + title: 'Paris' + )) + ->addMarker(new Marker( + position: new Point(45.7640, 4.8357), + title: 'Lyon', + // With an info window + infoWindow: new InfoWindow( + headerContent: 'Lyon', + content: 'The French town in the historic Rhône-Alpes region, located at the junction of the Rhône and Saône rivers.' + ) + )); + + // 3. And inject the map in your template to render it + return $this->render('contact/index.html.twig', [ + 'my_map' => $myMap, + ]); + } + } + +To render a map in your Twig template, use the ``render_map`` Twig function, e.g.: + +.. code-block:: twig + + {{ render_map(my_map) }} + + {# or with custom attributes #} + {{ render_map(my_map, { style: 'height: 300px' }) }} + +Extend the default behavior +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Symfony UX Map allows you to extend its default behavior using a custom Stimulus controller: + +.. code-block:: javascript + + // assets/controllers/mymap_controller.js + + import { Controller } from '@hotwired/stimulus'; + + export default class extends Controller { + connect() { + this.element.addEventListener('ux:map:pre-connect', this._onPreConnect); + this.element.addEventListener('ux:map:connect', this._onConnect); + this.element.addEventListener('ux:map:marker:before-create', this._onMarkerBeforeConnect); + this.element.addEventListener('ux:map:marker:after-create', this._onMarkerAfterCreate); + this.element.addEventListener('ux:map:info-window:before-create', this._onInfoWindowBeforeConnect); + this.element.addEventListener('ux:map:info-window:after-create', this._onInfoWindowAfterCreate); + } + + disconnect() { + // You should always remove listeners when the controller is disconnected to avoid side effects + this.element.removeEventListener('ux:map:pre-connect', this._onPreConnect); + this.element.removeEventListener('ux:map:connect', this._onConnect); + this.element.removeEventListener('ux:map:marker:before-create', this._onMarkerBeforeConnect); + this.element.removeEventListener('ux:map:marker:after-create', this._onMarkerAfterCreate); + this.element.removeEventListener('ux:map:info-window:before-create', this._onInfoWindowBeforeConnect); + this.element.removeEventListener('ux:map:info-window:after-create', this._onInfoWindowAfterCreate); + } + + _onPreConnect(event) { + // The map is not created yet + // You can use this event to configure the map before it is created + console.log(event.detail.options); + } + + _onConnect(event) { + // The map, markers and infoWindows are created + // The instances depend on the renderer you are using + console.log(event.detail.map); + console.log(event.detail.markers); + console.log(event.detail.infoWindows); + } + + _onMarkerBeforeConnect(event) { + // The marker is not created yet + // You can use this event to configure the marker before it is created + console.log(event.detail.definition); + } + + _onMarkerAfterCreate(event) { + // The marker is created + // The instance depends on the renderer you are using + console.log(event.detail.marker); + } + + _onInfoWindowBeforeConnect(event) { + // The infoWindow is not created yet + // You can use this event to configure the infoWindow before it is created + console.log(event.detail.definition); + // The associated marker instance is also available + console.log(event.detail.marker); + } + + _onInfoWindowAfterCreate(event) { + // The infoWindow is created + // The instance depends on the renderer you are using + console.log(event.detail.infoWindow); + // The associated marker instance is also available + console.log(event.detail.marker); + } + } + +Then, you can use this controller in your template: + +.. code-block:: twig + + {{ render_map(my_map, { 'data-controller': 'mymap', style: 'height: 300px' }) }} + +Backward Compatibility promise +------------------------------ + +This bundle aims at following the same Backward Compatibility promise as +the Symfony framework: +https://symfony.com/doc/current/contributing/code/bc.html + +.. _`the Symfony UX initiative`: https://symfony.com/ux +.. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html +.. _`Google`: https://github.com/symfony/symfony-ux/blob/{version}/src/Map/src/Bridge/Google/README.md +.. _`Leaflet`: https://github.com/symfony/symfony-ux/blob/{version}/src/Map/src/Bridge/Leaflet/README.md diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..56e43f7 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + + ./src + + + + + + + + + + + tests + + + diff --git a/src/Exception/Exception.php b/src/Exception/Exception.php new file mode 100644 index 0000000..82e977a --- /dev/null +++ b/src/Exception/Exception.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +/** + * @author Hugo Alliaume + */ +interface Exception extends \Throwable +{ +} diff --git a/src/Exception/IncompleteDsnException.php b/src/Exception/IncompleteDsnException.php new file mode 100644 index 0000000..a12d01a --- /dev/null +++ b/src/Exception/IncompleteDsnException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +/** + * @author Hugo Alliaume + */ +final class IncompleteDsnException extends InvalidArgumentException +{ +} diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..aa28085 --- /dev/null +++ b/src/Exception/InvalidArgumentException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +/** + * @author Hugo Alliaume + */ +class InvalidArgumentException extends \InvalidArgumentException implements Exception +{ +} diff --git a/src/Exception/LogicException.php b/src/Exception/LogicException.php new file mode 100644 index 0000000..6cf0251 --- /dev/null +++ b/src/Exception/LogicException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +/** + * @author Hugo Alliaume + */ +class LogicException extends \LogicException implements Exception +{ +} diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php new file mode 100644 index 0000000..ec2b5ef --- /dev/null +++ b/src/Exception/RuntimeException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +/** + * @author Hugo Alliaume + */ +class RuntimeException extends \RuntimeException implements Exception +{ +} diff --git a/src/Exception/UnsupportedSchemeException.php b/src/Exception/UnsupportedSchemeException.php new file mode 100644 index 0000000..cfec4fd --- /dev/null +++ b/src/Exception/UnsupportedSchemeException.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +use Symfony\UX\Map\Renderer\Dsn; +use Symfony\UX\Map\UXMapBundle; + +/** + * @author Hugo Alliaume + */ +class UnsupportedSchemeException extends InvalidArgumentException +{ + public function __construct(Dsn $dsn, ?\Throwable $previous = null) + { + $provider = $dsn->getScheme(); + $bridge = UXMapBundle::$bridges[$provider] ?? null; + if ($bridge && !class_exists($bridge['renderer_factory'])) { + parent::__construct(\sprintf('Unable to render maps via "%s" as the bridge is not installed. Try running "composer require symfony/ux-map-%s".', $provider, $provider)); + + return; + } + + parent::__construct( + \sprintf('The renderer "%s" is not supported.', $dsn->getScheme()), + 0, + $previous + ); + } +} diff --git a/src/InfoWindow.php b/src/InfoWindow.php new file mode 100644 index 0000000..df43292 --- /dev/null +++ b/src/InfoWindow.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +/** + * Represents an information window that can be displayed on a map. + * + * @author Hugo Alliaume + */ +final readonly class InfoWindow +{ + public function __construct( + private ?string $headerContent = null, + private ?string $content = null, + private ?Point $position = null, + private bool $opened = false, + private bool $autoClose = true, + ) { + } + + public function toArray(): array + { + return [ + 'headerContent' => $this->headerContent, + 'content' => $this->content, + 'position' => $this->position?->toArray(), + 'opened' => $this->opened, + 'autoClose' => $this->autoClose, + ]; + } +} diff --git a/src/Map.php b/src/Map.php new file mode 100644 index 0000000..77ad765 --- /dev/null +++ b/src/Map.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +use Symfony\UX\Map\Exception\InvalidArgumentException; + +/** + * Represents a map. + * + * @author Hugo Alliaume + */ +final class Map +{ + public function __construct( + private readonly ?string $rendererName = null, + private ?MapOptionsInterface $options = null, + private ?Point $center = null, + private ?float $zoom = null, + private bool $fitBoundsToMarkers = false, + /** + * @var array + */ + private array $markers = [], + ) { + } + + public function getRendererName(): ?string + { + return $this->rendererName; + } + + public function center(Point $center): self + { + $this->center = $center; + + return $this; + } + + public function zoom(float $zoom): self + { + $this->zoom = $zoom; + + return $this; + } + + public function fitBoundsToMarkers(bool $enable = true): self + { + $this->fitBoundsToMarkers = $enable; + + return $this; + } + + public function options(MapOptionsInterface $options): self + { + $this->options = $options; + + return $this; + } + + public function getOptions(): ?MapOptionsInterface + { + return $this->options; + } + + public function hasOptions(): bool + { + return null !== $this->options; + } + + public function addMarker(Marker $marker): self + { + $this->markers[] = $marker; + + return $this; + } + + public function toArray(): array + { + if (null === $this->center) { + throw new InvalidArgumentException('The center of the map must be set.'); + } + + if (null === $this->zoom) { + throw new InvalidArgumentException('The zoom of the map must be set.'); + } + + return [ + 'center' => $this->center->toArray(), + 'zoom' => $this->zoom, + 'fitBoundsToMarkers' => $this->fitBoundsToMarkers, + 'options' => (object) ($this->options?->toArray() ?? []), + 'markers' => array_map(static fn (Marker $marker) => $marker->toArray(), $this->markers), + ]; + } +} diff --git a/src/MapOptionsInterface.php b/src/MapOptionsInterface.php new file mode 100644 index 0000000..de7b1e2 --- /dev/null +++ b/src/MapOptionsInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +/** + * @author Hugo Alliaume + */ +interface MapOptionsInterface +{ + /** + * @return array + */ + public function toArray(): array; +} diff --git a/src/Marker.php b/src/Marker.php new file mode 100644 index 0000000..b33a27c --- /dev/null +++ b/src/Marker.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +/** + * Represents a marker on a map. + * + * @author Hugo Alliaume + */ +final readonly class Marker +{ + public function __construct( + private Point $position, + private ?string $title = null, + private ?InfoWindow $infoWindow = null, + ) { + } + + public function toArray(): array + { + return [ + 'position' => $this->position->toArray(), + 'title' => $this->title, + 'infoWindow' => $this->infoWindow?->toArray(), + ]; + } +} diff --git a/src/Point.php b/src/Point.php new file mode 100644 index 0000000..a6d71d8 --- /dev/null +++ b/src/Point.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +use Symfony\UX\Map\Exception\InvalidArgumentException; + +/** + * Represents a geographical point. + * + * @author Hugo Alliaume + */ +final readonly class Point +{ + public function __construct( + public float $latitude, + public float $longitude, + ) { + if ($latitude < -90 || $latitude > 90) { + throw new InvalidArgumentException(\sprintf('Latitude must be between -90 and 90 degrees, "%s" given.', $latitude)); + } + + if ($longitude < -180 || $longitude > 180) { + throw new InvalidArgumentException(\sprintf('Longitude must be between -180 and 180 degrees, "%s" given.', $longitude)); + } + } + + /** + * @return array{lat: float, lng: float} + */ + public function toArray(): array + { + return [ + 'lat' => $this->latitude, + 'lng' => $this->longitude, + ]; + } +} diff --git a/src/Renderer/AbstractRenderer.php b/src/Renderer/AbstractRenderer.php new file mode 100644 index 0000000..df392c0 --- /dev/null +++ b/src/Renderer/AbstractRenderer.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Renderer; + +use Symfony\UX\Map\Map; +use Symfony\UX\Map\MapOptionsInterface; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; + +/** + * @author Hugo Alliaume + */ +abstract readonly class AbstractRenderer implements RendererInterface +{ + public function __construct( + private StimulusHelper $stimulus, + ) { + } + + abstract protected function getName(): string; + + abstract protected function getProviderOptions(): array; + + abstract protected function getDefaultMapOptions(): MapOptionsInterface; + + final public function renderMap(Map $map, array $attributes = []): string + { + if (!$map->hasOptions()) { + $map->options($this->getDefaultMapOptions()); + } elseif (!$map->getOptions() instanceof ($defaultMapOptions = $this->getDefaultMapOptions())) { + $map->options($defaultMapOptions); + } + + $stimulusAttributes = $this->stimulus->createStimulusAttributes(); + foreach ($attributes as $name => $value) { + if ('data-controller' === $name) { + continue; + } + + if (true === $value) { + $stimulusAttributes->addAttribute($name, $name); + } elseif (false !== $value) { + $stimulusAttributes->addAttribute($name, $value); + } + } + + $stimulusAttributes->addController( + '@symfony/ux-map-'.$this->getName().'/map', + [ + 'provider-options' => (object) $this->getProviderOptions(), + 'view' => $map->toArray(), + ] + ); + + return \sprintf('
', $stimulusAttributes); + } +} diff --git a/src/Renderer/AbstractRendererFactory.php b/src/Renderer/AbstractRendererFactory.php new file mode 100644 index 0000000..02587a7 --- /dev/null +++ b/src/Renderer/AbstractRendererFactory.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Renderer; + +use Symfony\UX\Map\Exception\IncompleteDsnException; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; + +/** + * @author Hugo Alliaume + */ +abstract class AbstractRendererFactory +{ + public function __construct( + protected StimulusHelper $stimulus, + ) { + } + + public function supports(Dsn $dsn): bool + { + return \in_array($dsn->getScheme(), $this->getSupportedSchemes(), true); + } + + protected function getUser(Dsn $dsn): string + { + return $dsn->getUser() ?? throw new IncompleteDsnException('User is not set.'); + } + + /** + * @return string[] + */ + abstract protected function getSupportedSchemes(): array; +} diff --git a/src/Renderer/Dsn.php b/src/Renderer/Dsn.php new file mode 100644 index 0000000..ecac16d --- /dev/null +++ b/src/Renderer/Dsn.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Renderer; + +use Symfony\UX\Map\Exception\InvalidArgumentException; + +/** + * @author Hugo Alliaume + */ +final readonly class Dsn +{ + private string $scheme; + private string $host; + private ?string $user; + private array $options; + private string $originalDsn; + + public function __construct(#[\SensitiveParameter] string $dsn) + { + $this->originalDsn = $dsn; + + if (false === $params = parse_url($dsn)) { + throw new InvalidArgumentException('The map renderer DSN is invalid.'); + } + + if (!isset($params['scheme'])) { + throw new InvalidArgumentException('The map renderer DSN must contain a scheme.'); + } + $this->scheme = $params['scheme']; + + if (!isset($params['host'])) { + throw new InvalidArgumentException('The map renderer DSN must contain a host (use "default" by default).'); + } + $this->host = $params['host']; + + $this->user = '' !== ($params['user'] ?? '') ? rawurldecode($params['user']) : null; + + $options = []; + parse_str($params['query'] ?? '', $options); + $this->options = $options; + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getHost(): string + { + return $this->host; + } + + public function getUser(): ?string + { + return $this->user; + } + + public function getOption(string $key, mixed $default = null): mixed + { + return $this->options[$key] ?? $default; + } + + public function getOptions(): array + { + return $this->options; + } + + public function getOriginalDsn(): string + { + return $this->originalDsn; + } +} diff --git a/src/Renderer/NullRenderer.php b/src/Renderer/NullRenderer.php new file mode 100644 index 0000000..76ab4a2 --- /dev/null +++ b/src/Renderer/NullRenderer.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Renderer; + +use Symfony\UX\Map\Exception\LogicException; +use Symfony\UX\Map\Map; + +/** + * @author Hugo Alliaume + * + * @internal + */ +final readonly class NullRenderer implements RendererInterface +{ + public function __construct( + private array $availableBridges = [], + ) { + } + + public function renderMap(Map $map, array $attributes = []): string + { + $message = 'You must install at least one bridge package to use the Symfony UX Map component.'; + if ($this->availableBridges) { + $message .= \PHP_EOL.'Try running '.implode(' or ', array_map(fn ($name) => \sprintf('"composer require %s"', $name), $this->availableBridges)).'.'; + } + + throw new LogicException($message); + } + + public function __toString(): string + { + return 'null://null'; + } +} diff --git a/src/Renderer/NullRendererFactory.php b/src/Renderer/NullRendererFactory.php new file mode 100644 index 0000000..0d2c28a --- /dev/null +++ b/src/Renderer/NullRendererFactory.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Renderer; + +use Symfony\UX\Map\Exception\UnsupportedSchemeException; + +final readonly class NullRendererFactory implements RendererFactoryInterface +{ + /** + * @param array $availableBridges + */ + public function __construct( + private array $availableBridges = [], + ) { + } + + public function create(Dsn $dsn): RendererInterface + { + if (!$this->supports($dsn)) { + throw new UnsupportedSchemeException($dsn); + } + + return new NullRenderer($this->availableBridges); + } + + public function supports(Dsn $dsn): bool + { + return 'null' === $dsn->getScheme(); + } +} diff --git a/src/Renderer/Renderer.php b/src/Renderer/Renderer.php new file mode 100644 index 0000000..ca2da7f --- /dev/null +++ b/src/Renderer/Renderer.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Renderer; + +use Symfony\UX\Map\Exception\UnsupportedSchemeException; + +/** + * @author Hugo Alliaume + * + * @internal + */ +final readonly class Renderer +{ + public function __construct( + /** + * @param iterable $factories + */ + private iterable $factories, + ) { + } + + public function fromStrings(#[\SensitiveParameter] array $dsns): Renderers + { + $renderers = []; + foreach ($dsns as $name => $dsn) { + $renderers[$name] = $this->fromString($dsn); + } + + return new Renderers($renderers); + } + + public function fromString(#[\SensitiveParameter] string $dsn): RendererInterface + { + return $this->fromDsnObject(new Dsn($dsn)); + } + + public function fromDsnObject(Dsn $dsn): RendererInterface + { + foreach ($this->factories as $factory) { + if ($factory->supports($dsn)) { + return $factory->create($dsn); + } + } + + throw new UnsupportedSchemeException($dsn); + } +} diff --git a/src/Renderer/RendererFactoryInterface.php b/src/Renderer/RendererFactoryInterface.php new file mode 100644 index 0000000..254c1bf --- /dev/null +++ b/src/Renderer/RendererFactoryInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Renderer; + +/** + * @author Hugo Alliaume + */ +interface RendererFactoryInterface +{ + public function create(Dsn $dsn): RendererInterface; + + public function supports(Dsn $dsn): bool; +} diff --git a/src/Renderer/RendererInterface.php b/src/Renderer/RendererInterface.php new file mode 100644 index 0000000..2b43aca --- /dev/null +++ b/src/Renderer/RendererInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Renderer; + +use Symfony\UX\Map\Map; + +/** + * @author Hugo Alliaume + */ +interface RendererInterface extends \Stringable +{ + /** + * @param array $attributes an array of HTML attributes + */ + public function renderMap(Map $map, array $attributes = []): string; +} diff --git a/src/Renderer/Renderers.php b/src/Renderer/Renderers.php new file mode 100644 index 0000000..ea7fae8 --- /dev/null +++ b/src/Renderer/Renderers.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Renderer; + +use Symfony\UX\Map\Exception\LogicException; +use Symfony\UX\Map\Map; + +/** + * @author Hugo Alliaume + * + * @internal + */ +final class Renderers implements RendererInterface +{ + /** + * @var array + */ + private array $renderers = []; + private RendererInterface $default; + + /** + * @param iterable $renderers + */ + public function __construct(iterable $renderers) + { + foreach ($renderers as $name => $renderer) { + $this->default ??= $renderer; + $this->renderers[$name] = $renderer; + } + + if (!$this->renderers) { + throw new LogicException(\sprintf('"%s" must have at least one renderer configured.', __CLASS__)); + } + } + + public function renderMap(Map $map, array $attributes = []): string + { + $renderer = $this->default; + + if ($rendererName = $map->getRendererName()) { + if (!isset($this->renderers[$rendererName])) { + throw new LogicException(\sprintf('The "%s" renderer does not exist (available renderers: "%s").', $rendererName, implode('", "', array_keys($this->renderers)))); + } + + $renderer = $this->renderers[$rendererName]; + } + + return $renderer->renderMap($map, $attributes); + } + + public function __toString() + { + return implode(', ', array_keys($this->renderers)); + } +} diff --git a/src/Test/RendererFactoryTestCase.php b/src/Test/RendererFactoryTestCase.php new file mode 100644 index 0000000..6d8914e --- /dev/null +++ b/src/Test/RendererFactoryTestCase.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Test; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\UnsupportedSchemeException; +use Symfony\UX\Map\Renderer\Dsn; +use Symfony\UX\Map\Renderer\RendererFactoryInterface; + +/** + * A test case to ease testing a renderer factory. + * + * @author Oskar Stark + * @author Hugo Alliaume + */ +abstract class RendererFactoryTestCase extends TestCase +{ + abstract public function createRendererFactory(): RendererFactoryInterface; + + /** + * @return iterable + */ + abstract public static function supportsRenderer(): iterable; + + /** + * @return iterable + */ + abstract public static function createRenderer(): iterable; + + /** + * @return iterable + */ + public static function unsupportedSchemeRenderer(): iterable + { + return []; + } + + /** + * @return iterable + */ + public static function incompleteDsnRenderer(): iterable + { + return []; + } + + /** + * @dataProvider supportsRenderer + */ + public function testSupports(bool $expected, string $dsn): void + { + $factory = $this->createRendererFactory(); + + $this->assertSame($expected, $factory->supports(new Dsn($dsn))); + } + + /** + * @dataProvider createRenderer + */ + public function testCreate(string $expected, string $dsn): void + { + $factory = $this->createRendererFactory(); + $renderer = $factory->create(new Dsn($dsn)); + + $this->assertSame($expected, (string) $renderer); + } + + /** + * @dataProvider unsupportedSchemeRenderer + */ + public function testUnsupportedSchemeException(string $dsn, ?string $message = null): void + { + $factory = $this->createRendererFactory(); + + $dsn = new Dsn($dsn); + + $this->expectException(UnsupportedSchemeException::class); + if (null !== $message) { + $this->expectExceptionMessage($message); + } + + $factory->create($dsn); + } +} diff --git a/src/Test/RendererTestCase.php b/src/Test/RendererTestCase.php new file mode 100644 index 0000000..b9c3fe0 --- /dev/null +++ b/src/Test/RendererTestCase.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Test; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Map; +use Symfony\UX\Map\Renderer\RendererInterface; + +/** + * A test case to ease testing a renderer. + */ +abstract class RendererTestCase extends TestCase +{ + /** + * @return iterable}> + */ + abstract public function provideTestRenderMap(): iterable; + + /** + * @dataProvider provideTestRenderMap + */ + public function testRenderMap(string $expectedRender, RendererInterface $renderer, Map $map, array $attributes = []): void + { + self::assertSame($expectedRender, $renderer->renderMap($map, $attributes)); + } +} diff --git a/src/Twig/MapExtension.php b/src/Twig/MapExtension.php new file mode 100644 index 0000000..b55e5de --- /dev/null +++ b/src/Twig/MapExtension.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Twig; + +use Symfony\UX\Map\Renderer\Renderers; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * @author Hugo Alliaume + * + * @internal + */ +final class MapExtension extends AbstractExtension +{ + public function getFunctions(): iterable + { + yield new TwigFunction('render_map', [Renderers::class, 'renderMap'], ['is_safe' => ['html']]); + } +} diff --git a/src/UXMapBundle.php b/src/UXMapBundle.php new file mode 100644 index 0000000..9f7f39e --- /dev/null +++ b/src/UXMapBundle.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Bundle\AbstractBundle; +use Symfony\UX\Map\Bridge as MapBridge; +use Symfony\UX\Map\Renderer\AbstractRendererFactory; +use Symfony\UX\Map\Renderer\NullRendererFactory; + +/** + * @author Hugo Alliaume + */ +final class UXMapBundle extends AbstractBundle +{ + protected string $extensionAlias = 'ux_map'; + + /** + * @var array }> + * + * @internal + */ + public static array $bridges = [ + 'google' => ['renderer_factory' => MapBridge\Google\Renderer\GoogleRendererFactory::class], + 'leaflet' => ['renderer_factory' => MapBridge\Leaflet\Renderer\LeafletRendererFactory::class], + ]; + + public function configure(DefinitionConfigurator $definition): void + { + $rootNode = $definition->rootNode(); + $rootNode + ->children() + ->scalarNode('renderer')->defaultNull()->end() + ->end() + ; + } + + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->import('../config/services.php'); + + if (!isset($config['renderer'])) { + $config['renderer'] = 'null://null'; + } + + if (str_starts_with($config['renderer'], 'null://')) { + $container->services() + ->set('ux_map.renderer_factory.null', NullRendererFactory::class) + ->arg(0, array_map(fn ($name) => 'symfony/ux-map-'.$name, array_keys(self::$bridges))) + ->tag('ux_map.renderer_factory'); + } + + $renderers = ['default' => $config['renderer']]; + $container->services() + ->get('ux_map.renderers') + ->arg(0, $renderers); + + foreach (self::$bridges as $name => $bridge) { + if (ContainerBuilder::willBeAvailable('symfony/ux-map-'.$name, $bridge['renderer_factory'], ['symfony/ux-map'])) { + $container->services() + ->set('ux_map.renderer_factory.'.$name, $bridge['renderer_factory']) + ->parent('ux_map.renderer_factory.abstract') + ->tag('ux_map.renderer_factory'); + } + } + } + + public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void + { + if (!$this->isAssetMapperAvailable()) { + return; + } + + $paths = [ + __DIR__.'/../assets/dist' => '@symfony/ux-map', + ]; + + foreach (self::$bridges as $name => $bridge) { + if (ContainerBuilder::willBeAvailable('symfony/ux-map-'.$name, $bridge['renderer_factory'], ['symfony/ux-map'])) { + $rendererFactoryReflection = new \ReflectionClass($bridge['renderer_factory']); + $bridgePath = \dirname($rendererFactoryReflection->getFileName(), 3); + $paths[$bridgePath.'/assets/dist'] = '@symfony/ux-map-'.$name; + } + } + + $builder->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => $paths, + ], + ]); + } + + private function isAssetMapperAvailable(): bool + { + return interface_exists(AssetMapperInterface::class); + } +} diff --git a/tests/InfoWindowTest.php b/tests/InfoWindowTest.php new file mode 100644 index 0000000..ca6325b --- /dev/null +++ b/tests/InfoWindowTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\InfoWindow; +use Symfony\UX\Map\Point; + +class InfoWindowTest extends TestCase +{ + public function testToArray(): void + { + $infoWindow = new InfoWindow( + headerContent: 'Paris', + content: 'Capitale de la France, est une grande ville européenne et un centre mondial de l\'art, de la mode, de la gastronomie et de la culture.', + position: new Point(48.8566, 2.3522), + opened: true, + autoClose: false, + ); + + self::assertSame([ + 'headerContent' => 'Paris', + 'content' => 'Capitale de la France, est une grande ville européenne et un centre mondial de l\'art, de la mode, de la gastronomie et de la culture.', + 'position' => [ + 'lat' => 48.8566, + 'lng' => 2.3522, + ], + 'opened' => true, + 'autoClose' => false, + ], $infoWindow->toArray()); + } +} diff --git a/tests/Kernel/AppKernelTrait.php b/tests/Kernel/AppKernelTrait.php new file mode 100644 index 0000000..593759b --- /dev/null +++ b/tests/Kernel/AppKernelTrait.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Kernel; + +/** + * @author Hugo Alliaume + * + * @internal + */ +trait AppKernelTrait +{ + public function getCacheDir(): string + { + return $this->createTmpDir('cache'); + } + + public function getLogDir(): string + { + return $this->createTmpDir('logs'); + } + + private function createTmpDir(string $type): string + { + $dir = sys_get_temp_dir().'/map_bundle/'.uniqid($type.'_', true); + + if (!file_exists($dir)) { + mkdir($dir, 0777, true); + } + + return $dir; + } +} diff --git a/tests/Kernel/FrameworkAppKernel.php b/tests/Kernel/FrameworkAppKernel.php new file mode 100644 index 0000000..1246a8d --- /dev/null +++ b/tests/Kernel/FrameworkAppKernel.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Kernel; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\Map\UXMapBundle; +use Symfony\UX\StimulusBundle\StimulusBundle; + +/** + * @author Hugo Alliaume + * + * @internal + */ +class FrameworkAppKernel extends Kernel +{ + use AppKernelTrait; + + public function registerBundles(): iterable + { + return [new FrameworkBundle(), new StimulusBundle(), new UXMapBundle()]; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); + $container->loadFromExtension('ux_map', []); + + $container->setAlias('test.ux_map.renderers', 'ux_map.renderers')->setPublic(true); + }); + } +} diff --git a/tests/Kernel/TwigAppKernel.php b/tests/Kernel/TwigAppKernel.php new file mode 100644 index 0000000..364b843 --- /dev/null +++ b/tests/Kernel/TwigAppKernel.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Kernel; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\Map\UXMapBundle; +use Symfony\UX\StimulusBundle\StimulusBundle; + +/** + * @author Hugo Alliaume + * + * @internal + */ +class TwigAppKernel extends Kernel +{ + use AppKernelTrait; + + public function registerBundles(): iterable + { + return [new FrameworkBundle(), new StimulusBundle(), new TwigBundle(), new UXMapBundle()]; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); + $container->loadFromExtension('twig', ['default_path' => __DIR__.'/templates', 'strict_variables' => true, 'exception_controller' => null]); + $container->loadFromExtension('ux_map', []); + + $container->setAlias('test.ux_map.renderers', 'ux_map.renderers')->setPublic(true); + }); + } +} diff --git a/tests/MapTest.php b/tests/MapTest.php new file mode 100644 index 0000000..7ce579e --- /dev/null +++ b/tests/MapTest.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\InfoWindow; +use Symfony\UX\Map\Map; +use Symfony\UX\Map\MapOptionsInterface; +use Symfony\UX\Map\Marker; +use Symfony\UX\Map\Point; + +class MapTest extends TestCase +{ + public function testCenterValidation(): void + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('The center of the map must be set.'); + + $map = new Map(); + $map->toArray(); + } + + public function testZoomValidation(): void + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('The zoom of the map must be set.'); + + $map = new Map( + center: new Point(48.8566, 2.3522) + ); + $map->toArray(); + } + + public function testWithMinimumConfiguration(): void + { + $map = new Map(); + $map + ->center(new Point(48.8566, 2.3522)) + ->zoom(6); + + $array = $map->toArray(); + + self::assertSame([ + 'center' => ['lat' => 48.8566, 'lng' => 2.3522], + 'zoom' => 6.0, + 'fitBoundsToMarkers' => false, + 'options' => $array['options'], + 'markers' => [], + ], $array); + } + + public function testWithMaximumConfiguration(): void + { + $map = new Map(); + $map + ->center(new Point(48.8566, 2.3522)) + ->zoom(6) + ->fitBoundsToMarkers() + ->options(new class() implements MapOptionsInterface { + public function toArray(): array + { + return [ + 'mapTypeId' => 'roadmap', + ]; + } + }) + ->addMarker(new Marker( + position: new Point(48.8566, 2.3522), + title: 'Paris', + infoWindow: new InfoWindow(headerContent: 'Paris', content: 'Paris', position: new Point(48.8566, 2.3522)) + )) + ->addMarker(new Marker( + position: new Point(45.764, 4.8357), + title: 'Lyon', + infoWindow: new InfoWindow(headerContent: 'Lyon', content: 'Lyon', position: new Point(45.764, 4.8357), opened: true) + )) + ->addMarker(new Marker( + position: new Point(43.2965, 5.3698), + title: 'Marseille', + infoWindow: new InfoWindow(headerContent: 'Marseille', content: 'Marseille', position: new Point(43.2965, 5.3698), opened: true) + )); + + $array = $map->toArray(); + + self::assertSame([ + 'center' => ['lat' => 48.8566, 'lng' => 2.3522], + 'zoom' => 6.0, + 'fitBoundsToMarkers' => true, + 'options' => $array['options'], + 'markers' => [ + [ + 'position' => ['lat' => 48.8566, 'lng' => 2.3522], + 'title' => 'Paris', + 'infoWindow' => ['headerContent' => 'Paris', 'content' => 'Paris', 'position' => ['lat' => 48.8566, 'lng' => 2.3522], 'opened' => false, 'autoClose' => true], + ], + [ + 'position' => ['lat' => 45.764, 'lng' => 4.8357], + 'title' => 'Lyon', + 'infoWindow' => ['headerContent' => 'Lyon', 'content' => 'Lyon', 'position' => ['lat' => 45.764, 'lng' => 4.8357], 'opened' => true, 'autoClose' => true], + ], + [ + 'position' => ['lat' => 43.2965, 'lng' => 5.3698], + 'title' => 'Marseille', + 'infoWindow' => ['headerContent' => 'Marseille', 'content' => 'Marseille', 'position' => ['lat' => 43.2965, 'lng' => 5.3698], 'opened' => true, 'autoClose' => true], + ], + ], + ], $array); + + self::assertSame('roadmap', $array['options']->mapTypeId); + } +} diff --git a/tests/MarkerTest.php b/tests/MarkerTest.php new file mode 100644 index 0000000..00468e8 --- /dev/null +++ b/tests/MarkerTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\InfoWindow; +use Symfony\UX\Map\Marker; +use Symfony\UX\Map\Point; + +class MarkerTest extends TestCase +{ + public function testToArray(): void + { + $marker = new Marker( + position: new Point(48.8566, 2.3522), + ); + + self::assertSame([ + 'position' => ['lat' => 48.8566, 'lng' => 2.3522], + 'title' => null, + 'infoWindow' => null, + ], $marker->toArray()); + + $marker = new Marker( + position: new Point(48.8566, 2.3522), + title: 'Paris', + infoWindow: new InfoWindow( + headerContent: 'Paris', + content: "Capitale de la France, est une grande ville européenne et un centre mondial de l'art, de la mode, de la gastronomie et de la culture.", + opened: true, + ), + ); + + self::assertSame([ + 'position' => ['lat' => 48.8566, 'lng' => 2.3522], + 'title' => 'Paris', + 'infoWindow' => [ + 'headerContent' => 'Paris', + 'content' => "Capitale de la France, est une grande ville européenne et un centre mondial de l'art, de la mode, de la gastronomie et de la culture.", + 'position' => null, + 'opened' => true, + 'autoClose' => true, + ], + ], $marker->toArray()); + } +} diff --git a/tests/PointTest.php b/tests/PointTest.php new file mode 100644 index 0000000..9610804 --- /dev/null +++ b/tests/PointTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\Point; + +class PointTest extends TestCase +{ + public static function provideInvalidPoint(): iterable + { + yield [91, 0, 'Latitude must be between -90 and 90 degrees, "91" given.']; + yield [-91, 0, 'Latitude must be between -90 and 90 degrees, "-91" given.']; + yield [0, 181, 'Longitude must be between -180 and 180 degrees, "181" given.']; + yield [0, -181, 'Longitude must be between -180 and 180 degrees, "-181" given.']; + } + + /** + * @dataProvider provideInvalidPoint + */ + public function testInvalidPoint(float $latitude, float $longitude, string $expectedExceptionMessage): void + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage($expectedExceptionMessage); + + new Point($latitude, $longitude); + } + + public function testToArray(): void + { + $point = new Point(48.8566, 2.3533); + + self::assertSame(['lat' => 48.8566, 'lng' => 2.3533], $point->toArray()); + } +} diff --git a/tests/Renderer/DsnTest.php b/tests/Renderer/DsnTest.php new file mode 100644 index 0000000..52766a4 --- /dev/null +++ b/tests/Renderer/DsnTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Renderer; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\Renderer\Dsn; + +final class DsnTest extends TestCase +{ + /** + * @dataProvider constructDsn + */ + public function testConstruct(string $dsnString, string $scheme, string $host, ?string $user = null, array $options = [], ?string $path = null) + { + $dsn = new Dsn($dsnString); + self::assertSame($dsnString, $dsn->getOriginalDsn()); + + self::assertSame($scheme, $dsn->getScheme()); + self::assertSame($host, $dsn->getHost()); + self::assertSame($user, $dsn->getUser()); + self::assertSame($options, $dsn->getOptions()); + } + + public static function constructDsn(): iterable + { + yield 'simple dsn' => [ + 'scheme://default', + 'scheme', + 'default', + ]; + + yield 'simple dsn including @ sign, but no user/password/token' => [ + 'scheme://@default', + 'scheme', + 'default', + ]; + + yield 'simple dsn including : sign and @ sign, but no user/password/token' => [ + 'scheme://:@default', + 'scheme', + 'default', + ]; + + yield 'simple dsn including user, : sign and @ sign, but no password' => [ + 'scheme://user1:@default', + 'scheme', + 'default', + 'user1', + ]; + + yield 'dsn with user' => [ + 'scheme://u$er@default', + 'scheme', + 'default', + 'u$er', + ]; + + yield 'dsn with user, and custom option' => [ + 'scheme://u$er@default?api_key=MY_API_KEY', + 'scheme', + 'default', + 'u$er', + [ + 'api_key' => 'MY_API_KEY', + ], + '/channel', + ]; + } + + /** + * @dataProvider invalidDsn + */ + public function testInvalidDsn(string $dsnString, string $exceptionMessage) + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage($exceptionMessage); + + new Dsn($dsnString); + } + + public static function invalidDsn(): iterable + { + yield [ + 'leaflet://', + 'The map renderer DSN is invalid.', + ]; + + yield [ + '//default', + 'The map renderer DSN must contain a scheme.', + ]; + } +} diff --git a/tests/Renderer/NullRendererFactoryTest.php b/tests/Renderer/NullRendererFactoryTest.php new file mode 100644 index 0000000..8f9ffb2 --- /dev/null +++ b/tests/Renderer/NullRendererFactoryTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Renderer; + +use Symfony\UX\Map\Renderer\NullRendererFactory; +use Symfony\UX\Map\Renderer\RendererFactoryInterface; +use Symfony\UX\Map\Test\RendererFactoryTestCase; + +final class NullRendererFactoryTest extends RendererFactoryTestCase +{ + public function createRendererFactory(): RendererFactoryInterface + { + return new NullRendererFactory(); + } + + public static function supportsRenderer(): iterable + { + yield [true, 'null://null']; + yield [true, 'null://foobar']; + yield [false, 'google://GOOGLE_MAPS_API_KEY@default']; + yield [false, 'leaflet://default']; + } + + public static function createRenderer(): iterable + { + yield [ + 'null://null', + 'null://null', + ]; + } + + public static function unsupportedSchemeRenderer(): iterable + { + yield ['somethingElse://foo@default']; + } +} diff --git a/tests/Renderer/NullRendererTest.php b/tests/Renderer/NullRendererTest.php new file mode 100644 index 0000000..d5812bc --- /dev/null +++ b/tests/Renderer/NullRendererTest.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Renderer; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\LogicException; +use Symfony\UX\Map\Map; +use Symfony\UX\Map\Renderer\NullRenderer; +use Symfony\UX\Map\Renderer\RendererInterface; + +final class NullRendererTest extends TestCase +{ + public function provideTestRenderMap(): iterable + { + yield 'no bridges' => [ + 'expected_exception_message' => 'You must install at least one bridge package to use the Symfony UX Map component.', + 'renderer' => new NullRenderer(), + ]; + + yield 'one bridge' => [ + 'expected_exception_message' => 'You must install at least one bridge package to use the Symfony UX Map component.' + .\PHP_EOL.'Try running "composer require symfony/ux-map-leaflet".', + 'renderer' => new NullRenderer(['symfony/ux-map-leaflet']), + ]; + + yield 'two bridges' => [ + 'expected_exception_message' => 'You must install at least one bridge package to use the Symfony UX Map component.' + .\PHP_EOL.'Try running "composer require symfony/ux-map-leaflet" or "composer require symfony/ux-map-google".', + 'renderer' => new NullRenderer(['symfony/ux-map-leaflet', 'symfony/ux-map-google']), + ]; + } + + /** + * @dataProvider provideTestRenderMap + */ + public function testRenderMap(string $expectedExceptionMessage, RendererInterface $renderer): void + { + self::expectException(LogicException::class); + self::expectExceptionMessage($expectedExceptionMessage); + + $renderer->renderMap(new Map(), []); + } +} diff --git a/tests/Renderer/RendererTest.php b/tests/Renderer/RendererTest.php new file mode 100644 index 0000000..4c4a859 --- /dev/null +++ b/tests/Renderer/RendererTest.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Renderer; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\UnsupportedSchemeException; +use Symfony\UX\Map\Renderer\Renderer; +use Symfony\UX\Map\Renderer\RendererFactoryInterface; +use Symfony\UX\Map\Renderer\RendererInterface; + +final class RendererTest extends TestCase +{ + public function testUnsupportedSchemeException(): void + { + self::expectException(UnsupportedSchemeException::class); + self::expectExceptionMessage('The renderer "scheme" is not supported.'); + + $renderer = new Renderer([]); + $renderer->fromString('scheme://default'); + } + + public function testSupportedFactory(): void + { + $renderer = new Renderer([ + 'one' => $oneFactory = self::createMock(RendererFactoryInterface::class), + 'two' => $twoFactory = self::createMock(RendererFactoryInterface::class), + ]); + + $oneFactory->expects(self::once())->method('supports')->willReturn(false); + $twoFactory->expects(self::once())->method('supports')->willReturn(true); + $twoFactory->expects(self::once())->method('create')->willReturn($twoRenderer = self::createMock(RendererInterface::class)); + + $renderer = $renderer->fromString('scheme://default'); + + self::assertSame($twoRenderer, $renderer); + } +} diff --git a/tests/Renderer/RenderersTest.php b/tests/Renderer/RenderersTest.php new file mode 100644 index 0000000..cc19ac9 --- /dev/null +++ b/tests/Renderer/RenderersTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Renderer; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\LogicException; +use Symfony\UX\Map\Map; +use Symfony\UX\Map\Renderer\RendererInterface; +use Symfony\UX\Map\Renderer\Renderers; + +class RenderersTest extends TestCase +{ + public function testConstructWithoutRenderers(): void + { + self::expectException(LogicException::class); + self::expectExceptionMessage('"Symfony\UX\Map\Renderer\Renderers" must have at least one renderer configured.'); + + new Renderers([]); + } + + public function testRenderMapWithDefaultRenderer(): void + { + $defaultRenderer = $this->createMock(RendererInterface::class); + $defaultRenderer->expects(self::once())->method('renderMap')->willReturn('
'); + + $renderers = new Renderers(['default' => $defaultRenderer]); + + self::assertSame('
', $renderers->renderMap(new Map())); + } + + public function testRenderMapWithCustomRenderer(): void + { + $defaultRenderer = $this->createMock(RendererInterface::class); + $defaultRenderer->expects(self::never())->method('renderMap'); + + $customRenderer = $this->createMock(RendererInterface::class); + $customRenderer->expects(self::once())->method('renderMap')->willReturn('
'); + + $renderers = new Renderers(['default' => $defaultRenderer, 'custom' => $customRenderer]); + + $map = new Map(rendererName: 'custom'); + + self::assertSame('
', $renderers->renderMap($map)); + } + + public function testRenderMapWithUnknownRenderer(): void + { + self::expectException(LogicException::class); + self::expectExceptionMessage('The "unknown" renderer does not exist (available renderers: "default").'); + + $defaultRenderer = $this->createMock(RendererInterface::class); + $defaultRenderer->expects(self::never())->method('renderMap'); + + $renderers = new Renderers(['default' => $defaultRenderer]); + + $map = new Map(rendererName: 'unknown'); + + $renderers->renderMap($map); + } +} diff --git a/tests/TwigTest.php b/tests/TwigTest.php new file mode 100644 index 0000000..6864f63 --- /dev/null +++ b/tests/TwigTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Map\Map; +use Symfony\UX\Map\Renderer\RendererInterface; +use Symfony\UX\Map\Tests\Kernel\TwigAppKernel; +use Twig\Loader\ArrayLoader; +use Twig\Loader\ChainLoader; + +final class TwigTest extends KernelTestCase +{ + protected static function getKernelClass(): string + { + return TwigAppKernel::class; + } + + public function testRenderMap(): void + { + $map = new Map(); + $attributes = ['data-foo' => 'bar']; + + $renderer = self::createMock(RendererInterface::class); + $renderer + ->expects(self::once()) + ->method('renderMap') + ->with($map, $attributes) + ->willReturn('
') + ; + + self::getContainer()->set('test.ux_map.renderers', $renderer); + + /** @var \Twig\Environment $twig */ + $twig = self::getContainer()->get('twig'); + $twig->setLoader(new ChainLoader([ + new ArrayLoader([ + 'test' => '{{ render_map(map, attributes) }}', + ]), + $twig->getLoader(), + ])); + + self::assertSame( + '
', + $twig->render('test', ['map' => $map, 'attributes' => $attributes]) + ); + } +} diff --git a/tests/UXMapBundleTest.php b/tests/UXMapBundleTest.php new file mode 100644 index 0000000..4fb76df --- /dev/null +++ b/tests/UXMapBundleTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests; + +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\Map\Renderer\NullRenderer; +use Symfony\UX\Map\Renderer\RendererInterface; +use Symfony\UX\Map\Tests\Kernel\FrameworkAppKernel; +use Symfony\UX\Map\Tests\Kernel\TwigAppKernel; + +class UXMapBundleTest extends TestCase +{ + /** + * @return iterable}> + */ + public static function provideKernelClasses(): iterable + { + yield 'framework' => [FrameworkAppKernel::class]; + yield 'twig' => [TwigAppKernel::class]; + } + + /** + * @dataProvider provideKernelClasses + * + * @param class-string $kernelClass + */ + public function testBootKernel(string $kernelClass): void + { + $kernel = new $kernelClass('test', true); + $kernel->boot(); + + self::assertArrayHasKey('UXMapBundle', $kernel->getBundles()); + } + + /** + * @dataProvider provideKernelClasses + * + * @param class-string $kernelClass + */ + public function testNullRendererAsDefault(string $kernelClass): void + { + $expectedRenderer = new NullRenderer(['symfony/ux-map-google', 'symfony/ux-map-leaflet']); + + $kernel = new $kernelClass('test', true); + $kernel->boot(); + $container = $kernel->getContainer(); + + $defaultRenderer = $this->extractDefaultRenderer($container); + self::assertEquals($expectedRenderer, $defaultRenderer, 'The default renderer should be a NullRenderer.'); + + $renderers = $this->extractRenderers($container); + self::assertEquals(['default' => $expectedRenderer], $renderers, 'The renderers should only contain the main renderer, which is a NullRenderer.'); + } + + private function extractDefaultRenderer(ContainerInterface $container): RendererInterface + { + $renderers = $container->get('test.ux_map.renderers'); + + return \Closure::bind(fn () => $this->default, $renderers, $renderers::class)(); + } + + /** + * @return array + */ + private function extractRenderers(ContainerInterface $container): array + { + $renderers = $container->get('test.ux_map.renderers'); + + return \Closure::bind(fn () => $this->renderers, $renderers, $renderers::class)(); + } +}