diff --git a/.github/build-packages.php b/.github/build-packages.php index 5570b69e88f..d9dc870b006 100644 --- a/.github/build-packages.php +++ b/.github/build-packages.php @@ -9,7 +9,7 @@ use Symfony\Component\Finder\Finder; $finder = (new Finder()) - ->in(__DIR__.'/../src/*/') + ->in([__DIR__.'/../src/*/', __DIR__.'/../src/*/src/Bridge/*/']) ->depth(0) ->name('composer.json') ; @@ -44,6 +44,18 @@ $key = isset($packageData['require']['symfony/stimulus-bundle']) ? 'require' : 'require-dev'; $packageData[$key]['symfony/stimulus-bundle'] = '@dev'; } + + if (isset($packageData['require']['symfony/ux-map']) + || isset($packageData['require-dev']['symfony/ux-map']) + ) { + $repositories[] = [ + 'type' => 'path', + 'url' => '../../../', + ]; + $key = isset($packageData['require']['symfony/ux-map']) ? 'require' : 'require-dev'; + $packageData[$key]['symfony/ux-map'] = '@dev'; + } + if ($repositories) { $packageData['repositories'] = $repositories; } diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 96a0c126b02..cb42ed10aa2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -71,7 +71,7 @@ jobs: - id: components run: | - components=$(tree src -J -d -L 1 | jq -c '.[0].contents | map(.name)') + components=$(find src/ -mindepth 2 -type f -name composer.json -not -path "*/vendor/*" -printf '%h\n' | jq -R -s -c 'split("\n")[:-1] | map(. | sub("^src/";"")) | sort') echo "$components" echo "components=$components" >> $GITHUB_OUTPUT @@ -89,6 +89,12 @@ jobs: dependency-version: 'highest' component: ${{ fromJson(needs.tests-php-components.outputs.components )}} exclude: + - component: Map # does not support PHP 8.1 + php-version: '8.1' + - component: Map/src/Bridge/Google # does not support PHP 8.1 + php-version: '8.1' + - component: Map/src/Bridge/Leaflet # does not support PHP 8.1 + php-version: '8.1' - component: Swup # has no tests - component: Turbo # has its own workflow (test-turbo.yml) - component: Typed # has no tests diff --git a/bin/build_javascript.js b/bin/build_javascript.js index e44dcf9e842..7f17622392c 100644 --- a/bin/build_javascript.js +++ b/bin/build_javascript.js @@ -21,6 +21,8 @@ const files = [ // custom handling for StimulusBundle 'src/StimulusBundle/assets/src/loader.ts', 'src/StimulusBundle/assets/src/controllers.ts', + // custom handling for Bridge + ...glob.sync('src/*/src/Bridge/*/assets/src/*controller.ts'), ...glob.sync('src/*/assets/src/*controller.ts'), ]; diff --git a/biome.json b/biome.json index 47028db958b..bc898bb0845 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,18 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "files": { - "include": ["src/*/assets/src/**", "src/*/assets/test/**", "src/*/*.json", "src/*/*/md", "*.json", "*.md"], + "include": [ + "*.json", + "*.md", + "src/*/*.json", + "src/*/*/md", + "src/*/assets/src/**", + "src/*/assets/test/**", + "src/*/src/Bridge/*.json", + "src/*/src/Bridge/*.md", + "src/*/src/Bridge/*/assets/src/**", + "src/*/src/Bridge/*/assets/test/**" + ], "ignore": ["**/composer.json", "**/vendor", "**/node_modules"] }, "linter": { diff --git a/package.json b/package.json index 0042829a503..60340d501f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "private": true, - "workspaces": ["src/*/assets"], + "workspaces": ["src/*/assets", "src/*/src/Bridge/*/assets"], "scripts": { "build": "node bin/build_javascript.js && node bin/build_styles.js", "test": "bin/run-vitest-all.sh", diff --git a/rollup.config.js b/rollup.config.js index 3c201c2704a..4f8bff7298e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -46,11 +46,15 @@ const wildcardExternalsPlugin = (peerDependencies) => ({ const moveTypescriptDeclarationsPlugin = (packagePath) => ({ name: 'move-ts-declarations', writeBundle: async () => { - const files = glob.sync(path.join(packagePath, 'dist', '*', 'assets', 'src', '**/*.d.ts')); + const isBridge = packagePath.includes('src/Bridge'); + const globPattern = isBridge + ? path.join(packagePath, 'dist', packagePath.replace(/^src\//, ''), '**/*.d.ts') + : path.join(packagePath, 'dist', '*', 'assets', 'src', '**/*.d.ts') + const files = glob.sync(globPattern); files.forEach((file) => { - // a bit odd, but remove first 7 directories, which will leave + // a bit odd, but remove first 7 or 13 directories, which will leave // only the relative path to the file - const relativePath = file.split('/').slice(7).join('/'); + const relativePath = file.split('/').slice(isBridge ? 13 : 7).join('/'); const targetFile = path.join(packagePath, 'dist', relativePath); if (!fs.existsSync(path.dirname(targetFile))) { diff --git a/src/Map/.gitattributes b/src/Map/.gitattributes new file mode 100644 index 00000000000..35c1f46ae5d --- /dev/null +++ b/src/Map/.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/src/Map/.gitignore b/src/Map/.gitignore new file mode 100644 index 00000000000..50b321e33a2 --- /dev/null +++ b/src/Map/.gitignore @@ -0,0 +1,3 @@ +vendor +composer.lock +.phpunit.result.cache diff --git a/src/Map/.symfony.bundle.yaml b/src/Map/.symfony.bundle.yaml new file mode 100644 index 00000000000..6d9a74acb76 --- /dev/null +++ b/src/Map/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "doc" diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md new file mode 100644 index 00000000000..9603bd3c5f1 --- /dev/null +++ b/src/Map/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## Unreleased + +- Component added diff --git a/src/Map/LICENSE b/src/Map/LICENSE new file mode 100644 index 00000000000..e374a5c8339 --- /dev/null +++ b/src/Map/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/src/Map/README.md b/src/Map/README.md new file mode 100644 index 00000000000..d554e4163cd --- /dev/null +++ b/src/Map/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/src/Map/assets/dist/abstract_map_controller.d.ts b/src/Map/assets/dist/abstract_map_controller.d.ts new file mode 100644 index 00000000000..7e78dc6b36e --- /dev/null +++ b/src/Map/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/src/Map/assets/dist/abstract_map_controller.js b/src/Map/assets/dist/abstract_map_controller.js new file mode 100644 index 00000000000..324a29ce9fe --- /dev/null +++ b/src/Map/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/src/Map/assets/package.json b/src/Map/assets/package.json new file mode 100644 index 00000000000..2561ef55ef4 --- /dev/null +++ b/src/Map/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/src/Map/assets/src/abstract_map_controller.ts b/src/Map/assets/src/abstract_map_controller.ts new file mode 100644 index 00000000000..7b64d87267c --- /dev/null +++ b/src/Map/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/src/Map/assets/test/abstract_map_controller.test.ts b/src/Map/assets/test/abstract_map_controller.test.ts new file mode 100644 index 00000000000..1c1747718a0 --- /dev/null +++ b/src/Map/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/src/Map/composer.json b/src/Map/composer.json new file mode 100644 index 00000000000..73d39303f74 --- /dev/null +++ b/src/Map/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/src/Map/config/services.php b/src/Map/config/services.php new file mode 100644 index 00000000000..7dada5ec563 --- /dev/null +++ b/src/Map/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/src/Map/doc/index.rst b/src/Map/doc/index.rst new file mode 100644 index 00000000000..9ce87597f5c --- /dev/null +++ b/src/Map/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/src/Map/phpunit.xml.dist b/src/Map/phpunit.xml.dist new file mode 100644 index 00000000000..56e43f7c99d --- /dev/null +++ b/src/Map/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + + ./src + + + + + + + + + + + tests + + + diff --git a/src/Map/src/Bridge/Google/.gitattributes b/src/Map/src/Bridge/Google/.gitattributes new file mode 100644 index 00000000000..84c7add058f --- /dev/null +++ b/src/Map/src/Bridge/Google/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Map/src/Bridge/Google/.gitignore b/src/Map/src/Bridge/Google/.gitignore new file mode 100644 index 00000000000..c49a5d8df5c --- /dev/null +++ b/src/Map/src/Bridge/Google/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Map/src/Bridge/Google/CHANGELOG.md b/src/Map/src/Bridge/Google/CHANGELOG.md new file mode 100644 index 00000000000..2b5de26f0c4 --- /dev/null +++ b/src/Map/src/Bridge/Google/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## Unreleased + +- Bridge added diff --git a/src/Map/src/Bridge/Google/LICENSE b/src/Map/src/Bridge/Google/LICENSE new file mode 100644 index 00000000000..e374a5c8339 --- /dev/null +++ b/src/Map/src/Bridge/Google/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/src/Map/src/Bridge/Google/README.md b/src/Map/src/Bridge/Google/README.md new file mode 100644 index 00000000000..449db09b2ca --- /dev/null +++ b/src/Map/src/Bridge/Google/README.md @@ -0,0 +1,87 @@ +# Symfony UX Map: Google Maps + +[Google Maps](https://developers.google.com/maps/documentation/javascript/overview) integration for Symfony UX Map. + +## DSN example + +```dotenv +UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default + +# With options +UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default?version=weekly +UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default?language=fr®ion=FR +``` + +Available options: + +| Option | Description | Default | +|------------|------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------| +| `id` | The id of the script tag | `__googleMapsScriptId` | +| `language` | Force language, see [list of supported languages](https://developers.google.com/maps/faq#languagesupport) specified in the browser | The user's preferred language | +| `region` | Unicode region subtag identifiers compatible with [ISO 3166-1](https://en.wikipedia.org/wiki/ISO_3166-1) | | +| `nonce` | Use a cryptographic nonce attribute | | +| `retries` | The number of script load retries | 3 | +| `url` | Custom url to load the Google Maps API script | `https://maps.googleapis.com/maps/api/js` | +| `version` | The release channels or version numbers | `weekly` | + +## Map options + +You can use the `GoogleOptions` class to configure your `Map`:: + +```php +use Symfony\UX\Map\Bridge\Google\GoogleOptions; +use Symfony\UX\Map\Bridge\Google\Option\ControlPosition; +use Symfony\UX\Map\Bridge\Google\Option\FullscreenControlOptions; +use Symfony\UX\Map\Bridge\Google\Option\GestureHandling; +use Symfony\UX\Map\Bridge\Google\Option\MapTypeControlOptions; +use Symfony\UX\Map\Bridge\Google\Option\MapTypeControlStyle; +use Symfony\UX\Map\Bridge\Google\Option\StreetViewControlOptions; +use Symfony\UX\Map\Bridge\Google\Option\ZoomControlOptions; +use Symfony\UX\Map\Point; +use Symfony\UX\Map\Map; + +$map = (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(6); + +// To configure controls options, and some other options: +$googleOptions = (new GoogleOptions()) + ->mapId('YOUR_MAP_ID') + ->gestureHandling(GestureHandling::GREEDY) + ->backgroundColor('#f00') + ->doubleClickZoom(true) + ->zoomControlOptions(new ZoomControlOptions( + position: ControlPosition::BLOCK_START_INLINE_END, + )) + ->mapTypeControlOptions(new MapTypeControlOptions( + mapTypeIds: ['roadmap'], + position: ControlPosition::INLINE_END_BLOCK_START, + style: MapTypeControlStyle::DROPDOWN_MENU, + )) + ->streetViewControlOptions(new StreetViewControlOptions( + position: ControlPosition::BLOCK_END_INLINE_START, + )) + ->fullscreenControlOptions(new FullscreenControlOptions( + position: ControlPosition::INLINE_START_BLOCK_END, + )) +; + +// To disable controls: +$googleOptions = (new GoogleOptions()) + ->mapId('YOUR_MAP_ID') + ->zoomControl(false) + ->mapTypeControl(false) + ->streetViewControl(false) + ->fullscreenControl(false) +; + +// Add the custom options to the map +$map->options($googleOptions); +``` + +## 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/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts b/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts new file mode 100644 index 00000000000..406671b9489 --- /dev/null +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts @@ -0,0 +1,26 @@ +/// +import AbstractMapController from '@symfony/ux-map/abstract-map-controller'; +import type { Point, MarkerDefinition } from '@symfony/ux-map/abstract-map-controller'; +import type { LoaderOptions } from '@googlemaps/js-api-loader'; +type MapOptions = Pick; +export default class extends AbstractMapController { + static values: { + providerOptions: ObjectConstructor; + }; + providerOptionsValue: Pick; + connect(): Promise; + protected doCreateMap({ center, zoom, options, }: { + center: Point; + zoom: number; + options: MapOptions; + }): google.maps.Map; + protected doCreateMarker(definition: MarkerDefinition): google.maps.marker.AdvancedMarkerElement; + protected doCreateInfoWindow({ definition, marker, }: { + definition: MarkerDefinition['infoWindow']; + marker: google.maps.marker.AdvancedMarkerElement; + }): google.maps.InfoWindow; + private createTextOrElement; + private closeInfoWindowsExcept; + protected doFitBoundsToMarkers(): void; +} +export {}; diff --git a/src/Map/src/Bridge/Google/assets/dist/map_controller.js b/src/Map/src/Bridge/Google/assets/dist/map_controller.js new file mode 100644 index 00000000000..d74b1268469 --- /dev/null +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.js @@ -0,0 +1,103 @@ +import AbstractMapController from '@symfony/ux-map/abstract-map-controller'; +import { Loader } from '@googlemaps/js-api-loader'; + +let loader; +let library; +class default_1 extends AbstractMapController { + async connect() { + if (!loader) { + loader = new Loader(this.providerOptionsValue); + } + const { Map: _Map, InfoWindow } = await loader.importLibrary('maps'); + const { AdvancedMarkerElement } = await loader.importLibrary('marker'); + library = { _Map, AdvancedMarkerElement, InfoWindow }; + super.connect(); + } + doCreateMap({ center, zoom, options, }) { + options.zoomControl = typeof options.zoomControlOptions !== 'undefined'; + options.mapTypeControl = typeof options.mapTypeControlOptions !== 'undefined'; + options.streetViewControl = typeof options.streetViewControlOptions !== 'undefined'; + options.fullscreenControl = typeof options.fullscreenControlOptions !== 'undefined'; + return new library._Map(this.element, { + ...options, + center, + zoom, + }); + } + doCreateMarker(definition) { + const { position, title, infoWindow, rawOptions = {}, ...otherOptions } = definition; + const marker = new library.AdvancedMarkerElement({ + position, + title, + ...otherOptions, + ...rawOptions, + map: this.map, + }); + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, marker }); + } + return marker; + } + doCreateInfoWindow({ definition, marker, }) { + const { headerContent, content, rawOptions = {}, ...otherOptions } = definition; + const infoWindow = new library.InfoWindow({ + headerContent: this.createTextOrElement(headerContent), + content: this.createTextOrElement(content), + ...otherOptions, + ...rawOptions, + }); + if (definition.opened) { + infoWindow.open({ + map: this.map, + shouldFocus: false, + anchor: marker, + }); + } + marker.addListener('click', () => { + if (definition.autoClose) { + this.closeInfoWindowsExcept(infoWindow); + } + infoWindow.open({ + map: this.map, + anchor: marker, + }); + }); + return infoWindow; + } + createTextOrElement(content) { + if (!content) { + return null; + } + if (content.includes('<')) { + const div = document.createElement('div'); + div.innerHTML = content; + return div; + } + return content; + } + closeInfoWindowsExcept(infoWindow) { + this.infoWindows.forEach((otherInfoWindow) => { + if (otherInfoWindow !== infoWindow) { + otherInfoWindow.close(); + } + }); + } + doFitBoundsToMarkers() { + if (this.markers.length === 0) { + return; + } + const bounds = new google.maps.LatLngBounds(); + this.markers.forEach((marker) => { + if (!marker.position) { + return; + } + bounds.extend(marker.position); + }); + this.map.fitBounds(bounds); + } +} +default_1.values = { + providerOptions: Object, +}; + +export { default_1 as default }; diff --git a/src/Map/src/Bridge/Google/assets/package.json b/src/Map/src/Bridge/Google/assets/package.json new file mode 100644 index 00000000000..b189b4ff99e --- /dev/null +++ b/src/Map/src/Bridge/Google/assets/package.json @@ -0,0 +1,39 @@ +{ + "name": "@symfony/ux-map-google", + "description": "GoogleMaps bridge for Symfony UX Map, integrate interactive maps in your Symfony applications", + "license": "MIT", + "version": "1.0.0", + "type": "module", + "main": "dist/map_controller.js", + "types": "dist/map_controller.d.ts", + "symfony": { + "controllers": { + "map": { + "main": "dist/map_controller.js", + "webpackMode": "lazy", + "fetch": "lazy", + "enabled": true + } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0", + "@googlemaps/js-api-loader": "^1.16.6", + "@symfony/ux-map-google/map-controller": "path:%PACKAGE%/dist/map_controller.js" + } + }, + "peerDependencies": { + "@googlemaps/js-api-loader": "^1.16.6", + "@hotwired/stimulus": "^3.0.0" + }, + "peerDependenciesMeta": { + "@googlemaps/js-api-loader": { + "optional": false + } + }, + "devDependencies": { + "@googlemaps/js-api-loader": "^1.16.6", + "@hotwired/stimulus": "^3.0.0", + "@types/google.maps": "^3.55.9", + "happy-dom": "^14.12.3" + } +} diff --git a/src/Map/src/Bridge/Google/assets/src/map_controller.ts b/src/Map/src/Bridge/Google/assets/src/map_controller.ts new file mode 100644 index 00000000000..a0def42d1d7 --- /dev/null +++ b/src/Map/src/Bridge/Google/assets/src/map_controller.ts @@ -0,0 +1,187 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import AbstractMapController from '@symfony/ux-map/abstract-map-controller'; +import type { Point, MarkerDefinition } from '@symfony/ux-map/abstract-map-controller'; +import type { LoaderOptions } from '@googlemaps/js-api-loader'; +import { Loader } from '@googlemaps/js-api-loader'; + +type MapOptions = Pick< + google.maps.MapOptions, + | 'mapId' + | 'gestureHandling' + | 'backgroundColor' + | 'disableDoubleClickZoom' + | 'zoomControl' + | 'zoomControlOptions' + | 'mapTypeControl' + | 'mapTypeControlOptions' + | 'streetViewControl' + | 'streetViewControlOptions' + | 'fullscreenControl' + | 'fullscreenControlOptions' +>; + +let loader: Loader; +let library: { + _Map: typeof google.maps.Map; + AdvancedMarkerElement: typeof google.maps.marker.AdvancedMarkerElement; + InfoWindow: typeof google.maps.InfoWindow; +}; + +export default class extends AbstractMapController< + MapOptions, + google.maps.Map, + google.maps.marker.AdvancedMarkerElement, + google.maps.InfoWindow +> { + static values = { + providerOptions: Object, + }; + + declare providerOptionsValue: Pick< + LoaderOptions, + 'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version' + >; + + async connect() { + if (!loader) { + loader = new Loader(this.providerOptionsValue); + } + + const { Map: _Map, InfoWindow } = await loader.importLibrary('maps'); + const { AdvancedMarkerElement } = await loader.importLibrary('marker'); + library = { _Map, AdvancedMarkerElement, InfoWindow }; + + super.connect(); + } + + protected doCreateMap({ + center, + zoom, + options, + }: { + center: Point; + zoom: number; + options: MapOptions; + }): google.maps.Map { + // We assume the following control options are enabled if their options are set + options.zoomControl = typeof options.zoomControlOptions !== 'undefined'; + options.mapTypeControl = typeof options.mapTypeControlOptions !== 'undefined'; + options.streetViewControl = typeof options.streetViewControlOptions !== 'undefined'; + options.fullscreenControl = typeof options.fullscreenControlOptions !== 'undefined'; + + return new library._Map(this.element, { + ...options, + center, + zoom, + }); + } + + protected doCreateMarker( + definition: MarkerDefinition + ): google.maps.marker.AdvancedMarkerElement { + const { position, title, infoWindow, rawOptions = {}, ...otherOptions } = definition; + + const marker = new library.AdvancedMarkerElement({ + position, + title, + ...otherOptions, + ...rawOptions, + map: this.map, + }); + + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, marker }); + } + + return marker; + } + + protected doCreateInfoWindow({ + definition, + marker, + }: { + definition: MarkerDefinition< + google.maps.marker.AdvancedMarkerElementOptions, + google.maps.InfoWindowOptions + >['infoWindow']; + marker: google.maps.marker.AdvancedMarkerElement; + }): google.maps.InfoWindow { + const { headerContent, content, rawOptions = {}, ...otherOptions } = definition; + + const infoWindow = new library.InfoWindow({ + headerContent: this.createTextOrElement(headerContent), + content: this.createTextOrElement(content), + ...otherOptions, + ...rawOptions, + }); + + if (definition.opened) { + infoWindow.open({ + map: this.map, + shouldFocus: false, + anchor: marker, + }); + } + + marker.addListener('click', () => { + if (definition.autoClose) { + this.closeInfoWindowsExcept(infoWindow); + } + + infoWindow.open({ + map: this.map, + anchor: marker, + }); + }); + + return infoWindow; + } + + private createTextOrElement(content: string | null): string | HTMLElement | null { + if (!content) { + return null; + } + + // we assume it's HTML if it includes "<" + if (content.includes('<')) { + const div = document.createElement('div'); + div.innerHTML = content; + return div; + } + + return content; + } + + private closeInfoWindowsExcept(infoWindow: google.maps.InfoWindow) { + this.infoWindows.forEach((otherInfoWindow) => { + if (otherInfoWindow !== infoWindow) { + otherInfoWindow.close(); + } + }); + } + + protected doFitBoundsToMarkers(): void { + if (this.markers.length === 0) { + return; + } + + const bounds = new google.maps.LatLngBounds(); + this.markers.forEach((marker) => { + if (!marker.position) { + return; + } + + bounds.extend(marker.position); + }); + + this.map.fitBounds(bounds); + } +} diff --git a/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts b/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts new file mode 100644 index 00000000000..ebaf6375ab6 --- /dev/null +++ b/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts @@ -0,0 +1,62 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Application, Controller } from '@hotwired/stimulus'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import GoogleController from '../src/map_controller'; + +// Controller used to check the actual controller was properly booted +class CheckController extends Controller { + connect() { + this.element.addEventListener('ux:map:pre-connect', (event) => { + this.element.classList.add('pre-connected'); + }); + + this.element.addEventListener('ux:map:connect', (event) => { + this.element.classList.add('connected'); + }); + } +} + +const startStimulus = () => { + const application = Application.start(); + application.register('check', CheckController); + application.register('google', GoogleController); +}; + +describe('GoogleMapsController', () => { + let container: HTMLElement; + + beforeEach(() => { + container = mountDOM(` +
+ `); + }); + + afterEach(() => { + clearDOM(); + }); + + it('connect', async () => { + const div = getByTestId(container, 'map'); + expect(div).not.toHaveClass('pre-connected'); + expect(div).not.toHaveClass('connected'); + + startStimulus(); + await waitFor(() => expect(div).toHaveClass('pre-connected')); + await waitFor(() => expect(div).toHaveClass('connected')); + }); +}); diff --git a/src/Map/src/Bridge/Google/assets/vitest.config.js b/src/Map/src/Bridge/Google/assets/vitest.config.js new file mode 100644 index 00000000000..3892eefac50 --- /dev/null +++ b/src/Map/src/Bridge/Google/assets/vitest.config.js @@ -0,0 +1,17 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import configShared from '../../../../../../vitest.config.js' + +export default mergeConfig( + configShared, + defineConfig({ + resolve: { + alias: { + '@symfony/ux-map/abstract-map-controller': __dirname + '/../../../../assets/src/abstract_map_controller.ts', + }, + }, + test: { + // We need a browser(-like) environment to run the tests + environment: 'happy-dom', + }, + }) +); diff --git a/src/Map/src/Bridge/Google/composer.json b/src/Map/src/Bridge/Google/composer.json new file mode 100644 index 00000000000..5e8ea160507 --- /dev/null +++ b/src/Map/src/Bridge/Google/composer.json @@ -0,0 +1,33 @@ +{ + "name": "symfony/ux-map-google", + "type": "symfony-ux-map-bridge", + "description": "Symfony UX Map GoogleMaps Bridge", + "keywords": ["google-maps", "map", "symfony", "ux"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Hugo Alliaume", + "email": "hugo@alliau.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.3", + "symfony/ux-map": "^2.19" + }, + "require-dev": { + "symfony/phpunit-bridge": "^6.4|^7.0" + }, + "autoload": { + "psr-4": { "Symfony\\UX\\Map\\Bridge\\Google\\": "src/" }, + "exclude-from-classmap": [] + }, + "autoload-dev": { + "psr-4": { "Symfony\\UX\\Map\\Bridge\\Google\\Tests\\": "tests/" } + }, + "minimum-stability": "dev" +} diff --git a/src/Map/src/Bridge/Google/phpunit.xml.dist b/src/Map/src/Bridge/Google/phpunit.xml.dist new file mode 100644 index 00000000000..1c3807e6255 --- /dev/null +++ b/src/Map/src/Bridge/Google/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + + ./src + + + + + + + + + + + ./tests + + + diff --git a/src/Map/src/Bridge/Google/src/GoogleOptions.php b/src/Map/src/Bridge/Google/src/GoogleOptions.php new file mode 100644 index 00000000000..8b26efcfba9 --- /dev/null +++ b/src/Map/src/Bridge/Google/src/GoogleOptions.php @@ -0,0 +1,157 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google; + +use Symfony\UX\Map\Bridge\Google\Option\FullscreenControlOptions; +use Symfony\UX\Map\Bridge\Google\Option\GestureHandling; +use Symfony\UX\Map\Bridge\Google\Option\MapTypeControlOptions; +use Symfony\UX\Map\Bridge\Google\Option\StreetViewControlOptions; +use Symfony\UX\Map\Bridge\Google\Option\ZoomControlOptions; +use Symfony\UX\Map\MapOptionsInterface; + +/** + * @author Hugo Alliaume + */ +final class GoogleOptions implements MapOptionsInterface +{ + public function __construct( + private ?string $mapId = null, + private GestureHandling $gestureHandling = GestureHandling::AUTO, + private ?string $backgroundColor = null, + private bool $disableDoubleClickZoom = false, + private bool $zoomControl = true, + private ZoomControlOptions $zoomControlOptions = new ZoomControlOptions(), + private bool $mapTypeControl = true, + private MapTypeControlOptions $mapTypeControlOptions = new MapTypeControlOptions(), + private bool $streetViewControl = true, + private StreetViewControlOptions $streetViewControlOptions = new StreetViewControlOptions(), + private bool $fullscreenControl = true, + private FullscreenControlOptions $fullscreenControlOptions = new FullscreenControlOptions(), + ) { + } + + public function mapId(?string $mapId): self + { + $this->mapId = $mapId; + + return $this; + } + + public function gestureHandling(GestureHandling $gestureHandling): self + { + $this->gestureHandling = $gestureHandling; + + return $this; + } + + public function backgroundColor(?string $backgroundColor): self + { + $this->backgroundColor = $backgroundColor; + + return $this; + } + + public function doubleClickZoom(bool $enable = true): self + { + $this->disableDoubleClickZoom = !$enable; + + return $this; + } + + public function zoomControl(bool $enable = true): self + { + $this->zoomControl = $enable; + + return $this; + } + + public function zoomControlOptions(ZoomControlOptions $zoomControlOptions): self + { + $this->zoomControl = true; + $this->zoomControlOptions = $zoomControlOptions; + + return $this; + } + + public function mapTypeControl(bool $enable = true): self + { + $this->mapTypeControl = $enable; + + return $this; + } + + public function mapTypeControlOptions(MapTypeControlOptions $mapTypeControlOptions): self + { + $this->mapTypeControl = true; + $this->mapTypeControlOptions = $mapTypeControlOptions; + + return $this; + } + + public function streetViewControl(bool $enable = true): self + { + $this->streetViewControl = $enable; + + return $this; + } + + public function streetViewControlOptions(StreetViewControlOptions $streetViewControlOptions): self + { + $this->streetViewControl = true; + $this->streetViewControlOptions = $streetViewControlOptions; + + return $this; + } + + public function fullscreenControl(bool $enable = true): self + { + $this->fullscreenControl = $enable; + + return $this; + } + + public function fullscreenControlOptions(FullscreenControlOptions $fullscreenControlOptions): self + { + $this->fullscreenControl = true; + $this->fullscreenControlOptions = $fullscreenControlOptions; + + return $this; + } + + public function toArray(): array + { + $array = [ + 'mapId' => $this->mapId, + 'gestureHandling' => $this->gestureHandling->value, + 'backgroundColor' => $this->backgroundColor, + 'disableDoubleClickZoom' => $this->disableDoubleClickZoom, + ]; + + if ($this->zoomControl) { + $array['zoomControlOptions'] = $this->zoomControlOptions->toArray(); + } + + if ($this->mapTypeControl) { + $array['mapTypeControlOptions'] = $this->mapTypeControlOptions->toArray(); + } + + if ($this->streetViewControl) { + $array['streetViewControlOptions'] = $this->streetViewControlOptions->toArray(); + } + + if ($this->fullscreenControl) { + $array['fullscreenControlOptions'] = $this->fullscreenControlOptions->toArray(); + } + + return $array; + } +} diff --git a/src/Map/src/Bridge/Google/src/Option/ControlPosition.php b/src/Map/src/Bridge/Google/src/Option/ControlPosition.php new file mode 100644 index 00000000000..0ef209a40aa --- /dev/null +++ b/src/Map/src/Bridge/Google/src/Option/ControlPosition.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Option; + +/** + * @see https://developers.google.com/maps/documentation/javascript/reference/control#ControlPosition + * + * @author Hugo Alliaume + */ +enum ControlPosition: int +{ + /** + * Equivalent to bottom-center in both LTR and RTL. + */ + case BLOCK_END_INLINE_CENTER = 24; + + /** + * Equivalent to bottom-right in LTR, or bottom-left in RTL. + */ + case BLOCK_END_INLINE_END = 25; + + /** + * Equivalent to bottom-left in LTR, or bottom-right in RTL. + */ + case BLOCK_END_INLINE_START = 23; + + /** + * Equivalent to top-center in both LTR and RTL. + */ + case BLOCK_START_INLINE_CENTER = 15; + + /** + * Equivalent to top-right in LTR, or top-left in RTL. + */ + case BLOCK_START_INLINE_END = 16; + + /** + * Equivalent to top-left in LTR, or top-right in RTL. + */ + case BLOCK_START_INLINE_START = 14; + + /** + * Equivalent to right-center in LTR, or left-center in RTL. + */ + case INLINE_END_BLOCK_CENTER = 21; + + /** + * Equivalent to right-bottom in LTR, or left-bottom in RTL. + */ + case INLINE_END_BLOCK_END = 22; + + /** + * Equivalent to right-top in LTR, or left-top in RTL. + */ + case INLINE_END_BLOCK_START = 20; + + /** + * Equivalent to left-center in LTR, or right-center in RTL. + */ + case INLINE_START_BLOCK_CENTER = 17; + + /** + * Equivalent to left-bottom in LTR, or right-bottom in RTL. + */ + case INLINE_START_BLOCK_END = 19; + + /** + * Equivalent to left-top in LTR, or right-top in RTL. + */ + case INLINE_START_BLOCK_START = 18; +} diff --git a/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php b/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php new file mode 100644 index 00000000000..35256551f23 --- /dev/null +++ b/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Option; + +/** + * Options for the rendering of the fullscreen control. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/control#FullscreenControlOptions + * + * @author Hugo Alliaume + */ +final readonly class FullscreenControlOptions +{ + public function __construct( + private ControlPosition $position = ControlPosition::INLINE_END_BLOCK_START, + ) { + } + + public function toArray(): array + { + return [ + 'position' => $this->position->value, + ]; + } +} diff --git a/src/Map/src/Bridge/Google/src/Option/GestureHandling.php b/src/Map/src/Bridge/Google/src/Option/GestureHandling.php new file mode 100644 index 00000000000..ac8a354e30f --- /dev/null +++ b/src/Map/src/Bridge/Google/src/Option/GestureHandling.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\Bridge\Google\Option; + +/** + * This setting controls how the API handles gestures on the map. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/map#MapOptions.gestureHandling + * + * @author Hugo Alliaume + */ +enum GestureHandling: string +{ + /** + * Scroll events and one-finger touch gestures scroll the page, and do not zoom or pan the map. + * Two-finger touch gestures pan and zoom the map. + * Scroll events with a ctrl key or ⌘ key pressed zoom the map. + * In this mode the map cooperates with the page. + */ + case COOPERATIVE = 'cooperative'; + + /** + * All touch gestures and scroll events pan or zoom the map. + */ + case GREEDY = 'greedy'; + + /** + * The map cannot be panned or zoomed by user gestures. + */ + case NONE = 'none'; + + /** + * Gesture handling is either cooperative or greedy, depending on whether the page is scrollable or in an iframe. + */ + case AUTO = 'auto'; +} diff --git a/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php b/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php new file mode 100644 index 00000000000..99e1fba1fb7 --- /dev/null +++ b/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.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\Bridge\Google\Option; + +/** + * Options for the rendering of the map type control. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/control#MapTypeControlOptions + * + * @author Hugo Alliaume + */ +final readonly class MapTypeControlOptions +{ + /** + * @param array<'hybrid'|'roadmap'|'satellite'|'terrain'|string> $mapTypeIds + */ + public function __construct( + private array $mapTypeIds = [], + private ControlPosition $position = ControlPosition::BLOCK_START_INLINE_START, + private MapTypeControlStyle $style = MapTypeControlStyle::DEFAULT, + ) { + } + + public function toArray(): array + { + return [ + 'mapTypeIds' => $this->mapTypeIds, + 'position' => $this->position->value, + 'style' => $this->style->value, + ]; + } +} diff --git a/src/Map/src/Bridge/Google/src/Option/MapTypeControlStyle.php b/src/Map/src/Bridge/Google/src/Option/MapTypeControlStyle.php new file mode 100644 index 00000000000..5242ce4e5ef --- /dev/null +++ b/src/Map/src/Bridge/Google/src/Option/MapTypeControlStyle.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\Bridge\Google\Option; + +/** + * Identifiers for common MapTypesControls. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/control#MapTypeControlStyle + * + * @author Hugo Alliaume + */ +enum MapTypeControlStyle: int +{ + /** + * Uses the default map type control. When the DEFAULT control is shown, it will vary according to window size and other factors. + * The DEFAULT control may change in future versions of the API. + */ + case DEFAULT = 0; + + /** + * A dropdown menu for the screen realestate conscious. + */ + case DROPDOWN_MENU = 2; + + /** + * The standard horizontal radio buttons bar. + */ + case HORIZONTAL_BAR = 1; +} diff --git a/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php b/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php new file mode 100644 index 00000000000..926b8831945 --- /dev/null +++ b/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Option; + +/** + * Options for the rendering of the Street View pegman control on the map. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/control#StreetViewControlOptions + * + * @author Hugo Alliaume + */ +final readonly class StreetViewControlOptions +{ + public function __construct( + private ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, + ) { + } + + public function toArray(): array + { + return [ + 'position' => $this->position->value, + ]; + } +} diff --git a/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php b/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php new file mode 100644 index 00000000000..979947a2354 --- /dev/null +++ b/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Option; + +/** + * Options for the rendering of the zoom control. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/control#ZoomControlOptions + * + * @author Hugo Alliaume + */ +final readonly class ZoomControlOptions +{ + public function __construct( + private ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, + ) { + } + + public function toArray(): array + { + return [ + 'position' => $this->position->value, + ]; + } +} diff --git a/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php b/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php new file mode 100644 index 00000000000..fb214676dba --- /dev/null +++ b/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Renderer; + +use Symfony\UX\Map\Bridge\Google\GoogleOptions; +use Symfony\UX\Map\MapOptionsInterface; +use Symfony\UX\Map\Renderer\AbstractRenderer; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; + +/** + * @author Hugo Alliaume + * + * @internal + */ +final readonly class GoogleRenderer extends AbstractRenderer +{ + /** + * Parameters are based from https://googlemaps.github.io/js-api-loader/interfaces/LoaderOptions.html documentation. + */ + public function __construct( + StimulusHelper $stimulusHelper, + #[\SensitiveParameter] + private string $apiKey, + private ?string $id = null, + private ?string $language = null, + private ?string $region = null, + private ?string $nonce = null, + private ?int $retries = null, + private ?string $url = null, + private ?string $version = null, + ) { + parent::__construct($stimulusHelper); + } + + protected function getName(): string + { + return 'google'; + } + + protected function getProviderOptions(): array + { + return array_filter([ + 'id' => $this->id, + 'language' => $this->language, + 'region' => $this->region, + 'nonce' => $this->nonce, + 'retries' => $this->retries, + 'url' => $this->url, + 'version' => $this->version, + ]) + ['apiKey' => $this->apiKey]; + } + + protected function getDefaultMapOptions(): MapOptionsInterface + { + return new GoogleOptions(); + } + + public function __toString(): string + { + return \sprintf( + 'google://%s@default/?%s', + str_repeat('*', \strlen($this->apiKey)), + http_build_query(array_filter([ + 'id' => $this->id, + 'language' => $this->language, + 'region' => $this->region, + 'nonce' => $this->nonce, + 'retries' => $this->retries, + 'url' => $this->url, + 'version' => $this->version, + ])) + ); + } +} diff --git a/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php b/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php new file mode 100644 index 00000000000..04a39925050 --- /dev/null +++ b/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Renderer; + +use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\Exception\UnsupportedSchemeException; +use Symfony\UX\Map\Renderer\AbstractRendererFactory; +use Symfony\UX\Map\Renderer\Dsn; +use Symfony\UX\Map\Renderer\RendererFactoryInterface; +use Symfony\UX\Map\Renderer\RendererInterface; + +/** + * @author Hugo Alliaume + */ +final class GoogleRendererFactory extends AbstractRendererFactory implements RendererFactoryInterface +{ + public function create(Dsn $dsn): RendererInterface + { + if (!$this->supports($dsn)) { + throw new UnsupportedSchemeException($dsn); + } + + $apiKey = $dsn->getUser() ?: throw new InvalidArgumentException('The Google Maps renderer requires an API key as the user part of the DSN.'); + + return new GoogleRenderer( + $this->stimulus, + $apiKey, + id: $dsn->getOption('id'), + language: $dsn->getOption('language'), + region: $dsn->getOption('region'), + nonce: $dsn->getOption('nonce'), + retries: $dsn->getOption('retries'), + url: $dsn->getOption('url'), + version: $dsn->getOption('version', 'weekly'), + ); + } + + protected function getSupportedSchemes(): array + { + return ['google']; + } +} diff --git a/src/Map/src/Bridge/Google/tests/GoogleOptionsTest.php b/src/Map/src/Bridge/Google/tests/GoogleOptionsTest.php new file mode 100644 index 00000000000..ccde8a72939 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/GoogleOptionsTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Bridge\Google\GoogleOptions; +use Symfony\UX\Map\Bridge\Google\Option\ControlPosition; +use Symfony\UX\Map\Bridge\Google\Option\GestureHandling; +use Symfony\UX\Map\Bridge\Google\Option\MapTypeControlStyle; + +class GoogleOptionsTest extends TestCase +{ + public function testWithMinimalConfiguration(): void + { + $options = new GoogleOptions(); + + self::assertSame([ + 'mapId' => null, + 'gestureHandling' => 'auto', + 'backgroundColor' => null, + 'disableDoubleClickZoom' => false, + 'zoomControlOptions' => [ + 'position' => ControlPosition::INLINE_END_BLOCK_END->value, + ], + 'mapTypeControlOptions' => [ + 'mapTypeIds' => [], + 'position' => ControlPosition::BLOCK_START_INLINE_START->value, + 'style' => MapTypeControlStyle::DEFAULT->value, + ], + 'streetViewControlOptions' => [ + 'position' => ControlPosition::INLINE_END_BLOCK_END->value, + ], + 'fullscreenControlOptions' => [ + 'position' => ControlPosition::INLINE_END_BLOCK_START->value, + ], + ], $options->toArray()); + } + + public function testWithMinimalConfigurationAndWithoutControls(): void + { + $options = new GoogleOptions( + mapId: '2b2d73ba4b8c7b41', + gestureHandling: GestureHandling::GREEDY, + backgroundColor: '#f00', + disableDoubleClickZoom: true, + zoomControl: false, + mapTypeControl: false, + streetViewControl: false, + fullscreenControl: false, + ); + + self::assertSame([ + 'mapId' => '2b2d73ba4b8c7b41', + 'gestureHandling' => GestureHandling::GREEDY->value, + 'backgroundColor' => '#f00', + 'disableDoubleClickZoom' => true, + ], $options->toArray()); + } +} diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererFactoryTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererFactoryTest.php new file mode 100644 index 00000000000..98542249aa0 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/GoogleRendererFactoryTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Tests; + +use Symfony\UX\Map\Bridge\Google\Renderer\GoogleRendererFactory; +use Symfony\UX\Map\Renderer\RendererFactoryInterface; +use Symfony\UX\Map\Test\RendererFactoryTestCase; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; + +final class GoogleRendererFactoryTest extends RendererFactoryTestCase +{ + public function createRendererFactory(): RendererFactoryInterface + { + return new GoogleRendererFactory(new StimulusHelper(null)); + } + + public static function supportsRenderer(): iterable + { + yield [true, 'google://GOOGLE_MAPS_API_KEY@default']; + yield [false, 'somethingElse://login:apiKey@default']; + } + + public static function createRenderer(): iterable + { + yield [ + 'google://*******************@default/?version=weekly', + 'google://GOOGLE_MAPS_API_KEY@default', + ]; + + yield [ + 'google://*******************@default/?version=quartly', + 'google://GOOGLE_MAPS_API_KEY@default?version=quartly', + ]; + } + + public static function unsupportedSchemeRenderer(): iterable + { + yield ['somethingElse://foo@default']; + } +} diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php new file mode 100644 index 00000000000..7536992450a --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Tests; + +use Symfony\UX\Map\Bridge\Google\GoogleOptions; +use Symfony\UX\Map\Bridge\Google\Renderer\GoogleRenderer; +use Symfony\UX\Map\InfoWindow; +use Symfony\UX\Map\Map; +use Symfony\UX\Map\Marker; +use Symfony\UX\Map\Point; +use Symfony\UX\Map\Test\RendererTestCase; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; + +class GoogleRendererTest extends RendererTestCase +{ + public function provideTestRenderMap(): iterable + { + $map = (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12); + + yield 'simple map, with minimum options' => [ + 'expected_render' => '
', + 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'map' => $map, + ]; + + yield 'with every options' => [ + 'expected_render' => '
', + 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key', id: 'gmap', language: 'fr', region: 'FR', nonce: 'abcd', retries: 10, url: 'https://maps.googleapis.com/maps/api/js', version: 'quarterly'), + 'map' => $map, + ]; + + yield 'with markers and infoWindows' => [ + 'expected_render' => '
', + 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'map' => (clone $map) + ->addMarker(new Marker(new Point(48.8566, 2.3522), 'Paris')) + ->addMarker(new Marker(new Point(48.8566, 2.3522), 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'))), + ]; + + yield 'with controls enabled' => [ + 'expected_render' => '
', + 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'map' => (clone $map) + ->options(new GoogleOptions( + zoomControl: true, + mapTypeControl: true, + streetViewControl: true, + fullscreenControl: true, + )), + ]; + + yield 'without controls enabled' => [ + 'expected_render' => '
', + 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'map' => (clone $map) + ->options(new GoogleOptions( + zoomControl: false, + mapTypeControl: false, + streetViewControl: false, + fullscreenControl: false, + )), + ]; + } +} diff --git a/src/Map/src/Bridge/Google/tests/Option/ControlPositionTest.php b/src/Map/src/Bridge/Google/tests/Option/ControlPositionTest.php new file mode 100644 index 00000000000..71ef62e1167 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/Option/ControlPositionTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Tests\Option; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Bridge\Google\Option\ControlPosition; + +class ControlPositionTest extends TestCase +{ + public function testEnumValues(): void + { + self::assertSame(24, ControlPosition::BLOCK_END_INLINE_CENTER->value); + self::assertSame(25, ControlPosition::BLOCK_END_INLINE_END->value); + self::assertSame(23, ControlPosition::BLOCK_END_INLINE_START->value); + self::assertSame(15, ControlPosition::BLOCK_START_INLINE_CENTER->value); + self::assertSame(16, ControlPosition::BLOCK_START_INLINE_END->value); + self::assertSame(14, ControlPosition::BLOCK_START_INLINE_START->value); + self::assertSame(21, ControlPosition::INLINE_END_BLOCK_CENTER->value); + self::assertSame(22, ControlPosition::INLINE_END_BLOCK_END->value); + self::assertSame(20, ControlPosition::INLINE_END_BLOCK_START->value); + self::assertSame(17, ControlPosition::INLINE_START_BLOCK_CENTER->value); + self::assertSame(19, ControlPosition::INLINE_START_BLOCK_END->value); + self::assertSame(18, ControlPosition::INLINE_START_BLOCK_START->value); + } +} diff --git a/src/Map/src/Bridge/Google/tests/Option/FullscreenControlOptionsTest.php b/src/Map/src/Bridge/Google/tests/Option/FullscreenControlOptionsTest.php new file mode 100644 index 00000000000..3de7164df68 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/Option/FullscreenControlOptionsTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Tests\Option; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Bridge\Google\Option\ControlPosition; +use Symfony\UX\Map\Bridge\Google\Option\FullscreenControlOptions; + +class FullscreenControlOptionsTest extends TestCase +{ + public function testToArray(): void + { + $options = new FullscreenControlOptions( + position: ControlPosition::BLOCK_END_INLINE_CENTER + ); + + self::assertSame([ + 'position' => ControlPosition::BLOCK_END_INLINE_CENTER->value, + ], $options->toArray()); + } +} diff --git a/src/Map/src/Bridge/Google/tests/Option/GestureHandlingTest.php b/src/Map/src/Bridge/Google/tests/Option/GestureHandlingTest.php new file mode 100644 index 00000000000..c6a1b6b9930 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/Option/GestureHandlingTest.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Tests\Option; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Bridge\Google\Option\GestureHandling; + +class GestureHandlingTest extends TestCase +{ + public function testEnumValues(): void + { + self::assertSame('cooperative', GestureHandling::COOPERATIVE->value); + self::assertSame('greedy', GestureHandling::GREEDY->value); + self::assertSame('none', GestureHandling::NONE->value); + self::assertSame('auto', GestureHandling::AUTO->value); + } +} diff --git a/src/Map/src/Bridge/Google/tests/Option/MapTypeControlOptionsTest.php b/src/Map/src/Bridge/Google/tests/Option/MapTypeControlOptionsTest.php new file mode 100644 index 00000000000..fbc5ea19e81 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/Option/MapTypeControlOptionsTest.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\Bridge\Google\Tests\Option; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Bridge\Google\Option\ControlPosition; +use Symfony\UX\Map\Bridge\Google\Option\MapTypeControlOptions; +use Symfony\UX\Map\Bridge\Google\Option\MapTypeControlStyle; + +class MapTypeControlOptionsTest extends TestCase +{ + public function testToArray(): void + { + $options = new MapTypeControlOptions( + mapTypeIds: ['satellite', 'hybrid'], + position: ControlPosition::BLOCK_END_INLINE_END, + style: MapTypeControlStyle::HORIZONTAL_BAR, + ); + + self::assertSame([ + 'mapTypeIds' => ['satellite', 'hybrid'], + 'position' => ControlPosition::BLOCK_END_INLINE_END->value, + 'style' => MapTypeControlStyle::HORIZONTAL_BAR->value, + ], $options->toArray()); + } +} diff --git a/src/Map/src/Bridge/Google/tests/Option/MapTypeControlStyleTest.php b/src/Map/src/Bridge/Google/tests/Option/MapTypeControlStyleTest.php new file mode 100644 index 00000000000..43818e932ca --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/Option/MapTypeControlStyleTest.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\Bridge\Google\Tests\Option; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Bridge\Google\Option\MapTypeControlStyle; + +class MapTypeControlStyleTest extends TestCase +{ + public function testEnumValues(): void + { + self::assertSame(0, MapTypeControlStyle::DEFAULT->value); + self::assertSame(2, MapTypeControlStyle::DROPDOWN_MENU->value); + self::assertSame(1, MapTypeControlStyle::HORIZONTAL_BAR->value); + } +} diff --git a/src/Map/src/Bridge/Google/tests/Option/StreetViewControlOptionsTest.php b/src/Map/src/Bridge/Google/tests/Option/StreetViewControlOptionsTest.php new file mode 100644 index 00000000000..5cf0742b006 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/Option/StreetViewControlOptionsTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Tests\Option; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Bridge\Google\Option\ControlPosition; +use Symfony\UX\Map\Bridge\Google\Option\StreetViewControlOptions; + +class StreetViewControlOptionsTest extends TestCase +{ + public function testToArray(): void + { + $options = new StreetViewControlOptions( + position: ControlPosition::INLINE_END_BLOCK_CENTER + ); + + self::assertSame([ + 'position' => ControlPosition::INLINE_END_BLOCK_CENTER->value, + ], $options->toArray()); + } +} diff --git a/src/Map/src/Bridge/Google/tests/Option/ZoomControlOptionsTest.php b/src/Map/src/Bridge/Google/tests/Option/ZoomControlOptionsTest.php new file mode 100644 index 00000000000..929c960d432 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/Option/ZoomControlOptionsTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Google\Tests\Option; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Bridge\Google\Option\ControlPosition; +use Symfony\UX\Map\Bridge\Google\Option\ZoomControlOptions; + +class ZoomControlOptionsTest extends TestCase +{ + public function testToArray(): void + { + $options = new ZoomControlOptions( + position: ControlPosition::BLOCK_START_INLINE_END, + ); + + self::assertSame([ + 'position' => ControlPosition::BLOCK_START_INLINE_END->value, + ], $options->toArray()); + } +} diff --git a/src/Map/src/Bridge/Leaflet/.gitattributes b/src/Map/src/Bridge/Leaflet/.gitattributes new file mode 100644 index 00000000000..84c7add058f --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Map/src/Bridge/Leaflet/.gitignore b/src/Map/src/Bridge/Leaflet/.gitignore new file mode 100644 index 00000000000..c49a5d8df5c --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Map/src/Bridge/Leaflet/CHANGELOG.md b/src/Map/src/Bridge/Leaflet/CHANGELOG.md new file mode 100644 index 00000000000..2b5de26f0c4 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## Unreleased + +- Bridge added diff --git a/src/Map/src/Bridge/Leaflet/LICENSE b/src/Map/src/Bridge/Leaflet/LICENSE new file mode 100644 index 00000000000..e374a5c8339 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/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/src/Map/src/Bridge/Leaflet/README.md b/src/Map/src/Bridge/Leaflet/README.md new file mode 100644 index 00000000000..5948f4df2d3 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/README.md @@ -0,0 +1,45 @@ +# Symfony UX Map: Leaflet + +[Leaflet](https://leafletjs.com/) integration for Symfony UX Map. + +## DSN example + +```dotenv +UX_MAP_DSN=leaflet://default +``` + +## Map options + +You can use the `LeafletOptions` class to configure your `Map`:: + +```php +use Symfony\UX\Map\Bridge\Leaflet\LeafletOptions; +use Symfony\UX\Map\Bridge\Leaflet\Option\TileLayer; +use Symfony\UX\Map\Point; +use Symfony\UX\Map\Map; + +$map = (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(6); + +$leafletOptions = (new LeafletOptions()) + ->tileLayer(new TileLayer( + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap', + options: [ + 'minZoom' => 5, + 'maxZoom' => 10, + ] + )) +; + +// Add the custom options to the map +$map->options($leafletOptions); +``` + +## 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/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts new file mode 100644 index 00000000000..ce794285475 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts @@ -0,0 +1,27 @@ +import AbstractMapController from '@symfony/ux-map/abstract-map-controller'; +import type { Point, MarkerDefinition } from '@symfony/ux-map/abstract-map-controller'; +import 'leaflet/dist/leaflet.min.css'; +import { type Map as LeafletMap, Marker, type Popup } from 'leaflet'; +import type { MapOptions as LeafletMapOptions, MarkerOptions, PopupOptions } from 'leaflet'; +type MapOptions = Pick & { + tileLayer: { + url: string; + attribution: string; + options: Record; + }; +}; +export default class extends AbstractMapController { + connect(): void; + protected doCreateMap({ center, zoom, options }: { + center: Point; + zoom: number; + options: MapOptions; + }): LeafletMap; + protected doCreateMarker(definition: MarkerDefinition): Marker; + protected doCreateInfoWindow({ definition, marker, }: { + definition: MarkerDefinition['infoWindow']; + marker: Marker; + }): Popup; + protected doFitBoundsToMarkers(): void; +} +export {}; diff --git a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js new file mode 100644 index 00000000000..4ed62d89f5e --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js @@ -0,0 +1,59 @@ +import AbstractMapController from '@symfony/ux-map/abstract-map-controller'; +import 'leaflet/dist/leaflet.min.css'; +import { Marker, divIcon, map, tileLayer, marker } from 'leaflet'; + +class map_controller extends AbstractMapController { + connect() { + Marker.prototype.options.icon = divIcon({ + html: '', + iconSize: [25, 41], + iconAnchor: [12.5, 41], + popupAnchor: [0, -41], + className: '', + }); + super.connect(); + } + doCreateMap({ center, zoom, options }) { + const map$1 = map(this.element, { + ...options, + center, + zoom, + }); + tileLayer(options.tileLayer.url, { + attribution: options.tileLayer.attribution, + ...options.tileLayer.options, + }).addTo(map$1); + return map$1; + } + doCreateMarker(definition) { + const { position, title, infoWindow, rawOptions = {}, ...otherOptions } = definition; + const marker$1 = marker(position, { title, ...otherOptions, ...rawOptions }).addTo(this.map); + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, marker: marker$1 }); + } + return marker$1; + } + doCreateInfoWindow({ definition, marker, }) { + const { headerContent, content, rawOptions = {}, ...otherOptions } = definition; + marker.bindPopup(`${headerContent}
${content}`, { ...otherOptions, ...rawOptions }); + if (definition.opened) { + marker.openPopup(); + } + const popup = marker.getPopup(); + if (!popup) { + throw new Error('Unable to get the Popup associated to the Marker, this should not happens.'); + } + return popup; + } + doFitBoundsToMarkers() { + if (this.markers.length === 0) { + return; + } + this.map.fitBounds(this.markers.map((marker) => { + const position = marker.getLatLng(); + return [position.lat, position.lng]; + })); + } +} + +export { map_controller as default }; diff --git a/src/Map/src/Bridge/Leaflet/assets/package.json b/src/Map/src/Bridge/Leaflet/assets/package.json new file mode 100644 index 00000000000..bba9cadb343 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/assets/package.json @@ -0,0 +1,39 @@ +{ + "name": "@symfony/ux-map-leaflet", + "description": "Leaflet bridge for Symfony UX Map, integrate interactive maps in your Symfony applications", + "license": "MIT", + "version": "1.0.0", + "type": "module", + "main": "dist/map_controller.js", + "types": "dist/map_controller.d.ts", + "symfony": { + "controllers": { + "map": { + "main": "dist/map_controller.js", + "webpackMode": "lazy", + "fetch": "lazy", + "enabled": true + } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0", + "leaflet": "^1.9.4", + "@symfony/ux-map-leaflet/map-controller": "path:%PACKAGE%/dist/map_controller.js" + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0", + "leaflet": "^1.9.4" + }, + "peerDependenciesMeta": { + "leaflet": { + "optional": false + } + }, + "devDependencies": { + "@hotwired/stimulus": "^3.0.0", + "@types/leaflet": "^1.9.12", + "happy-dom": "^14.12.3", + "leaflet": "^1.9.4" + } +} diff --git a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts new file mode 100644 index 00000000000..ced6cc050b2 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts @@ -0,0 +1,99 @@ +import AbstractMapController from '@symfony/ux-map/abstract-map-controller'; +import type { Point, MarkerDefinition } from '@symfony/ux-map/abstract-map-controller'; +import 'leaflet/dist/leaflet.min.css'; +import { + map as createMap, + tileLayer as createTileLayer, + marker as createMarker, + divIcon, + type Map as LeafletMap, + Marker, + type Popup, +} from 'leaflet'; +import type { MapOptions as LeafletMapOptions, MarkerOptions, PopupOptions } from 'leaflet'; + +type MapOptions = Pick & { + tileLayer: { url: string; attribution: string; options: Record }; +}; + +export default class extends AbstractMapController< + MapOptions, + typeof LeafletMap, + MarkerOptions, + Marker, + Popup, + PopupOptions +> { + connect(): void { + Marker.prototype.options.icon = divIcon({ + html: '', + iconSize: [25, 41], + iconAnchor: [12.5, 41], + popupAnchor: [0, -41], + className: '', + }); + super.connect(); + } + + protected doCreateMap({ center, zoom, options }: { center: Point; zoom: number; options: MapOptions }): LeafletMap { + const map = createMap(this.element, { + ...options, + center, + zoom, + }); + + createTileLayer(options.tileLayer.url, { + attribution: options.tileLayer.attribution, + ...options.tileLayer.options, + }).addTo(map); + + return map; + } + + protected doCreateMarker(definition: MarkerDefinition): Marker { + const { position, title, infoWindow, rawOptions = {}, ...otherOptions } = definition; + + const marker = createMarker(position, { title, ...otherOptions, ...rawOptions }).addTo(this.map); + + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, marker }); + } + + return marker; + } + + protected doCreateInfoWindow({ + definition, + marker, + }: { + definition: MarkerDefinition['infoWindow']; + marker: Marker; + }): Popup { + const { headerContent, content, rawOptions = {}, ...otherOptions } = definition; + + marker.bindPopup(`${headerContent}
${content}`, { ...otherOptions, ...rawOptions }); + if (definition.opened) { + marker.openPopup(); + } + + const popup = marker.getPopup(); + if (!popup) { + throw new Error('Unable to get the Popup associated to the Marker, this should not happens.'); + } + return popup; + } + + protected doFitBoundsToMarkers(): void { + if (this.markers.length === 0) { + return; + } + + this.map.fitBounds( + this.markers.map((marker: Marker) => { + const position = marker.getLatLng(); + + return [position.lat, position.lng]; + }) + ); + } +} diff --git a/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts b/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts new file mode 100644 index 00000000000..e6aa9276e27 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts @@ -0,0 +1,62 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Application, Controller } from '@hotwired/stimulus'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import LeafletController from '../src/map_controller'; + +// Controller used to check the actual controller was properly booted +class CheckController extends Controller { + connect() { + this.element.addEventListener('ux:map:pre-connect', (event) => { + this.element.classList.add('pre-connected'); + }); + + this.element.addEventListener('ux:map:connect', (event) => { + this.element.classList.add('connected'); + }); + } +} + +const startStimulus = () => { + const application = Application.start(); + application.register('check', CheckController); + application.register('leaflet', LeafletController); +}; + +describe('LeafletController', () => { + let container: HTMLElement; + + beforeEach(() => { + container = mountDOM(` +
+ `); + }); + + afterEach(() => { + clearDOM(); + }); + + it('connect', async () => { + const div = getByTestId(container, 'map'); + expect(div).not.toHaveClass('pre-connected'); + expect(div).not.toHaveClass('connected'); + + startStimulus(); + await waitFor(() => expect(div).toHaveClass('pre-connected')); + await waitFor(() => expect(div).toHaveClass('connected')); + }); +}); diff --git a/src/Map/src/Bridge/Leaflet/assets/vitest.config.js b/src/Map/src/Bridge/Leaflet/assets/vitest.config.js new file mode 100644 index 00000000000..276e4b0d1be --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/assets/vitest.config.js @@ -0,0 +1,18 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import configShared from '../../../../../../vitest.config.js' + +export default mergeConfig( + configShared, + defineConfig({ + resolve: { + alias: { + '@symfony/ux-map/abstract-map-controller': __dirname + '/../../../../assets/src/abstract_map_controller.ts', + 'leaflet/dist/leaflet.min.css': 'leaflet/dist/leaflet.css', + }, + }, + test: { + // We need a browser(-like) environment to run the tests + environment: 'happy-dom', + }, + }) +); diff --git a/src/Map/src/Bridge/Leaflet/composer.json b/src/Map/src/Bridge/Leaflet/composer.json new file mode 100644 index 00000000000..ba26827ebea --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/composer.json @@ -0,0 +1,33 @@ +{ + "name": "symfony/ux-map-leaflet", + "type": "symfony-ux-map-bridge", + "description": "Symfony UX Map Leaflet Bridge", + "keywords": ["leaflet", "map", "symfony", "ux"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Hugo Alliaume", + "email": "hugo@alliau.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.3", + "symfony/ux-map": "^2.19" + }, + "require-dev": { + "symfony/phpunit-bridge": "^6.4|^7.0" + }, + "autoload": { + "psr-4": { "Symfony\\UX\\Map\\Bridge\\Leaflet\\": "src/" }, + "exclude-from-classmap": [] + }, + "autoload-dev": { + "psr-4": { "Symfony\\UX\\Map\\Bridge\\Leaflet\\Tests\\": "tests/" } + }, + "minimum-stability": "dev" +} diff --git a/src/Map/src/Bridge/Leaflet/phpunit.xml.dist b/src/Map/src/Bridge/Leaflet/phpunit.xml.dist new file mode 100644 index 00000000000..1c3807e6255 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + + ./src + + + + + + + + + + + ./tests + + + diff --git a/src/Map/src/Bridge/Leaflet/src/LeafletOptions.php b/src/Map/src/Bridge/Leaflet/src/LeafletOptions.php new file mode 100644 index 00000000000..0450477339d --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/src/LeafletOptions.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\Bridge\Leaflet; + +use Symfony\UX\Map\Bridge\Leaflet\Option\TileLayer; +use Symfony\UX\Map\MapOptionsInterface; + +/** + * @author Hugo Alliaume + */ +final class LeafletOptions implements MapOptionsInterface +{ + public function __construct( + private TileLayer $tileLayer = new TileLayer( + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap', + ), + ) { + } + + public function tileLayer(TileLayer $tileLayer): self + { + $this->tileLayer = $tileLayer; + + return $this; + } + + public function toArray(): array + { + return [ + 'tileLayer' => $this->tileLayer->toArray(), + ]; + } +} diff --git a/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php b/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php new file mode 100644 index 00000000000..526572538b3 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.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\Bridge\Leaflet\Option; + +/** + * Represents a tile layer for a Leaflet map. + * + * @see https://leafletjs.com/reference.html#tilelayer + * + * @author Hugo Alliaume + */ +final readonly class TileLayer +{ + /** + * @param array $options + */ + public function __construct( + private string $url, + private string $attribution, + private array $options = [], + ) { + } + + public function toArray(): array + { + return [ + 'url' => $this->url, + 'attribution' => $this->attribution, + 'options' => (object) $this->options, + ]; + } +} diff --git a/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php new file mode 100644 index 00000000000..05f1348ef72 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.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\Bridge\Leaflet\Renderer; + +use Symfony\UX\Map\Bridge\Leaflet\LeafletOptions; +use Symfony\UX\Map\MapOptionsInterface; +use Symfony\UX\Map\Renderer\AbstractRenderer; + +/** + * @author Hugo Alliaume + * + * @internal + */ +final readonly class LeafletRenderer extends AbstractRenderer +{ + protected function getName(): string + { + return 'leaflet'; + } + + protected function getProviderOptions(): array + { + return []; + } + + protected function getDefaultMapOptions(): MapOptionsInterface + { + return new LeafletOptions(); + } + + public function __toString(): string + { + return 'leaflet://default'; + } +} diff --git a/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php new file mode 100644 index 00000000000..d5dc0c5dbd3 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.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\Bridge\Leaflet\Renderer; + +use Symfony\UX\Map\Exception\UnsupportedSchemeException; +use Symfony\UX\Map\Renderer\AbstractRendererFactory; +use Symfony\UX\Map\Renderer\Dsn; +use Symfony\UX\Map\Renderer\RendererFactoryInterface; +use Symfony\UX\Map\Renderer\RendererInterface; + +/** + * @author Hugo Alliaume + */ +final class LeafletRendererFactory extends AbstractRendererFactory implements RendererFactoryInterface +{ + public function create(Dsn $dsn): RendererInterface + { + if (!$this->supports($dsn)) { + throw new UnsupportedSchemeException($dsn); + } + + return new LeafletRenderer($this->stimulus); + } + + protected function getSupportedSchemes(): array + { + return ['leaflet']; + } +} diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php new file mode 100644 index 00000000000..a5d50fba7a7 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.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\Bridge\Leaflet\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Bridge\Leaflet\LeafletOptions; +use Symfony\UX\Map\Bridge\Leaflet\Option\TileLayer; + +class LeafletOptionsTest extends TestCase +{ + public function testWithMinimalConfiguration(): void + { + $leafletOptions = new LeafletOptions(); + + $array = $leafletOptions->toArray(); + + self::assertSame([ + 'tileLayer' => [ + 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + 'attribution' => '© OpenStreetMap', + 'options' => $array['tileLayer']['options'], // stdClass + ], + ], $array); + } + + public function testWithMaximumConfiguration(): void + { + $leafletOptions = new LeafletOptions( + tileLayer: new TileLayer( + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap', + options: [ + 'maxZoom' => 19, + 'minZoom' => 1, + 'maxNativeZoom' => 18, + 'zoomOffset' => 0, + ], + ), + ); + + $array = $leafletOptions->toArray(); + + self::assertSame([ + 'tileLayer' => [ + 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + 'attribution' => '© OpenStreetMap', + 'options' => $array['tileLayer']['options'], // stdClass + ], + ], $array); + self::assertSame(19, $array['tileLayer']['options']->maxZoom); + self::assertSame(1, $array['tileLayer']['options']->minZoom); + self::assertSame(18, $array['tileLayer']['options']->maxNativeZoom); + self::assertSame(0, $array['tileLayer']['options']->zoomOffset); + } +} diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererFactoryTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererFactoryTest.php new file mode 100644 index 00000000000..7ead676cd7f --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererFactoryTest.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\Bridge\Leaflet\Tests; + +use Symfony\UX\Map\Bridge\Leaflet\Renderer\LeafletRendererFactory; +use Symfony\UX\Map\Renderer\RendererFactoryInterface; +use Symfony\UX\Map\Test\RendererFactoryTestCase; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; + +final class LeafletRendererFactoryTest extends RendererFactoryTestCase +{ + public function createRendererFactory(): RendererFactoryInterface + { + return new LeafletRendererFactory(new StimulusHelper(null)); + } + + public static function supportsRenderer(): iterable + { + yield [true, 'leaflet://default']; + yield [false, 'foo://default']; + } + + public static function createRenderer(): iterable + { + yield [ + 'leaflet://default', + 'leaflet://default', + ]; + } + + public static function unsupportedSchemeRenderer(): iterable + { + yield ['somethingElse://foo@default']; + } +} diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php new file mode 100644 index 00000000000..d2491618a63 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.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\Bridge\Leaflet\Tests; + +use Symfony\UX\Map\Bridge\Leaflet\Renderer\LeafletRenderer; +use Symfony\UX\Map\InfoWindow; +use Symfony\UX\Map\Map; +use Symfony\UX\Map\Marker; +use Symfony\UX\Map\Point; +use Symfony\UX\Map\Test\RendererTestCase; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; + +class LeafletRendererTest extends RendererTestCase +{ + public function provideTestRenderMap(): iterable + { + $map = (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12); + + yield 'simple map' => [ + 'expected_render' => '
', + 'renderer' => new LeafletRenderer(new StimulusHelper(null)), + 'map' => $map, + ]; + + yield 'with markers and infoWindows' => [ + 'expected_render' => '
', + 'renderer' => new LeafletRenderer(new StimulusHelper(null)), + 'map' => (clone $map) + ->addMarker(new Marker(new Point(48.8566, 2.3522), 'Paris')) + ->addMarker(new Marker(new Point(48.8566, 2.3522), 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'))), + ]; + } +} diff --git a/src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.php b/src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.php new file mode 100644 index 00000000000..c0c8f998d78 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.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\Bridge\Leaflet\Tests\Option; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Bridge\Leaflet\Option\TileLayer; + +class TileLayerTest extends TestCase +{ + public function testToArray() + { + $tileLayer = new TileLayer( + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap contributors', + options: [ + 'maxZoom' => 19, + ], + ); + + $array = $tileLayer->toArray(); + + self::assertSame([ + 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + 'attribution' => '© OpenStreetMap contributors', + 'options' => $array['options'], // stdClass + ], $array); + self::assertSame(19, $array['options']->maxZoom); + } +} diff --git a/src/Map/src/Exception/Exception.php b/src/Map/src/Exception/Exception.php new file mode 100644 index 00000000000..82e977ac9ea --- /dev/null +++ b/src/Map/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/Map/src/Exception/IncompleteDsnException.php b/src/Map/src/Exception/IncompleteDsnException.php new file mode 100644 index 00000000000..a12d01a1a27 --- /dev/null +++ b/src/Map/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/Map/src/Exception/InvalidArgumentException.php b/src/Map/src/Exception/InvalidArgumentException.php new file mode 100644 index 00000000000..aa280857e7b --- /dev/null +++ b/src/Map/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/Map/src/Exception/LogicException.php b/src/Map/src/Exception/LogicException.php new file mode 100644 index 00000000000..6cf0251371c --- /dev/null +++ b/src/Map/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/Map/src/Exception/RuntimeException.php b/src/Map/src/Exception/RuntimeException.php new file mode 100644 index 00000000000..ec2b5ef8b14 --- /dev/null +++ b/src/Map/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/Map/src/Exception/UnsupportedSchemeException.php b/src/Map/src/Exception/UnsupportedSchemeException.php new file mode 100644 index 00000000000..cfec4fda20a --- /dev/null +++ b/src/Map/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/Map/src/InfoWindow.php b/src/Map/src/InfoWindow.php new file mode 100644 index 00000000000..df432923f6d --- /dev/null +++ b/src/Map/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/src/Map.php b/src/Map/src/Map.php new file mode 100644 index 00000000000..77ad7656216 --- /dev/null +++ b/src/Map/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/Map/src/MapOptionsInterface.php b/src/Map/src/MapOptionsInterface.php new file mode 100644 index 00000000000..de7b1e20211 --- /dev/null +++ b/src/Map/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/Map/src/Marker.php b/src/Map/src/Marker.php new file mode 100644 index 00000000000..b33a27c9095 --- /dev/null +++ b/src/Map/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/Map/src/Point.php b/src/Map/src/Point.php new file mode 100644 index 00000000000..a6d71d88f69 --- /dev/null +++ b/src/Map/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/Map/src/Renderer/AbstractRenderer.php b/src/Map/src/Renderer/AbstractRenderer.php new file mode 100644 index 00000000000..df392c05d92 --- /dev/null +++ b/src/Map/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/Map/src/Renderer/AbstractRendererFactory.php b/src/Map/src/Renderer/AbstractRendererFactory.php new file mode 100644 index 00000000000..02587a75d09 --- /dev/null +++ b/src/Map/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/Map/src/Renderer/Dsn.php b/src/Map/src/Renderer/Dsn.php new file mode 100644 index 00000000000..ecac16ddff0 --- /dev/null +++ b/src/Map/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/Map/src/Renderer/NullRenderer.php b/src/Map/src/Renderer/NullRenderer.php new file mode 100644 index 00000000000..76ab4a22612 --- /dev/null +++ b/src/Map/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/Map/src/Renderer/NullRendererFactory.php b/src/Map/src/Renderer/NullRendererFactory.php new file mode 100644 index 00000000000..0d2c28a7fb6 --- /dev/null +++ b/src/Map/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/Map/src/Renderer/Renderer.php b/src/Map/src/Renderer/Renderer.php new file mode 100644 index 00000000000..ca2da7fa071 --- /dev/null +++ b/src/Map/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/Map/src/Renderer/RendererFactoryInterface.php b/src/Map/src/Renderer/RendererFactoryInterface.php new file mode 100644 index 00000000000..254c1bf9a51 --- /dev/null +++ b/src/Map/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/Map/src/Renderer/RendererInterface.php b/src/Map/src/Renderer/RendererInterface.php new file mode 100644 index 00000000000..2b43acac169 --- /dev/null +++ b/src/Map/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/Map/src/Renderer/Renderers.php b/src/Map/src/Renderer/Renderers.php new file mode 100644 index 00000000000..ea7fae8eed1 --- /dev/null +++ b/src/Map/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/Map/src/Test/RendererFactoryTestCase.php b/src/Map/src/Test/RendererFactoryTestCase.php new file mode 100644 index 00000000000..6d8914ef2b1 --- /dev/null +++ b/src/Map/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/Map/src/Test/RendererTestCase.php b/src/Map/src/Test/RendererTestCase.php new file mode 100644 index 00000000000..b9c3fe07244 --- /dev/null +++ b/src/Map/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/Map/src/Twig/MapExtension.php b/src/Map/src/Twig/MapExtension.php new file mode 100644 index 00000000000..b55e5de562d --- /dev/null +++ b/src/Map/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/Map/src/UXMapBundle.php b/src/Map/src/UXMapBundle.php new file mode 100644 index 00000000000..9f7f39e940c --- /dev/null +++ b/src/Map/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/src/Map/tests/InfoWindowTest.php b/src/Map/tests/InfoWindowTest.php new file mode 100644 index 00000000000..ca6325b3543 --- /dev/null +++ b/src/Map/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/src/Map/tests/Kernel/AppKernelTrait.php b/src/Map/tests/Kernel/AppKernelTrait.php new file mode 100644 index 00000000000..593759b5f12 --- /dev/null +++ b/src/Map/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/src/Map/tests/Kernel/FrameworkAppKernel.php b/src/Map/tests/Kernel/FrameworkAppKernel.php new file mode 100644 index 00000000000..1246a8d8d13 --- /dev/null +++ b/src/Map/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/src/Map/tests/Kernel/TwigAppKernel.php b/src/Map/tests/Kernel/TwigAppKernel.php new file mode 100644 index 00000000000..364b843d518 --- /dev/null +++ b/src/Map/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/src/Map/tests/MapTest.php b/src/Map/tests/MapTest.php new file mode 100644 index 00000000000..7ce579e46c9 --- /dev/null +++ b/src/Map/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/src/Map/tests/MarkerTest.php b/src/Map/tests/MarkerTest.php new file mode 100644 index 00000000000..00468e8478f --- /dev/null +++ b/src/Map/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/src/Map/tests/PointTest.php b/src/Map/tests/PointTest.php new file mode 100644 index 00000000000..961080400a6 --- /dev/null +++ b/src/Map/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/src/Map/tests/Renderer/DsnTest.php b/src/Map/tests/Renderer/DsnTest.php new file mode 100644 index 00000000000..52766a4318a --- /dev/null +++ b/src/Map/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/src/Map/tests/Renderer/NullRendererFactoryTest.php b/src/Map/tests/Renderer/NullRendererFactoryTest.php new file mode 100644 index 00000000000..8f9ffb29e44 --- /dev/null +++ b/src/Map/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/src/Map/tests/Renderer/NullRendererTest.php b/src/Map/tests/Renderer/NullRendererTest.php new file mode 100644 index 00000000000..d5812bc9ae7 --- /dev/null +++ b/src/Map/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/src/Map/tests/Renderer/RendererTest.php b/src/Map/tests/Renderer/RendererTest.php new file mode 100644 index 00000000000..4c4a8595806 --- /dev/null +++ b/src/Map/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/src/Map/tests/Renderer/RenderersTest.php b/src/Map/tests/Renderer/RenderersTest.php new file mode 100644 index 00000000000..cc19ac9984b --- /dev/null +++ b/src/Map/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/src/Map/tests/TwigTest.php b/src/Map/tests/TwigTest.php new file mode 100644 index 00000000000..6864f633036 --- /dev/null +++ b/src/Map/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/src/Map/tests/UXMapBundleTest.php b/src/Map/tests/UXMapBundleTest.php new file mode 100644 index 00000000000..4fb76df0ebb --- /dev/null +++ b/src/Map/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)(); + } +} diff --git a/tsconfig.json b/tsconfig.json index ac315e240de..c57ddd9188d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,11 @@ "allowSyntheticDefaultImports": true, "jsx": "react" }, - "exclude": ["src/*/assets/dist"], - "include": ["src/*/assets/src", "src/*/assets/test"] + "exclude": ["src/*/assets/dist", "src/*/src/Bridge/*/assets/dist"], + "include": [ + "src/*/assets/src", + "src/*/assets/test", + "src/*/src/Bridge/*/assets/src", + "src/*/src/Bridge/*/assets/test" + ] } diff --git a/yarn.lock b/yarn.lock index 6089543f567..a0030d02357 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2144,6 +2144,13 @@ dependencies: tslib "^2.4.0" +"@googlemaps/js-api-loader@^1.16.6": + version "1.16.6" + resolved "https://registry.yarnpkg.com/@googlemaps/js-api-loader/-/js-api-loader-1.16.6.tgz#c89970c94b55796d51746c092f0e52953994a171" + integrity sha512-V8p5W9DbPQx74jWUmyYJOerhiB4C+MHekaO0ZRmc6lrOYrvY7+syLhzOWpp55kqSPeNb+qbC2h8i69aLIX6krQ== + dependencies: + fast-deep-equal "^3.1.3" + "@hotwired/stimulus@^3.0.0": version "3.2.1" resolved "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.1.tgz" @@ -2826,6 +2833,16 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== +"@types/geojson@*": + version "7946.0.14" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.14.tgz#319b63ad6df705ee2a65a73ef042c8271e696613" + integrity sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg== + +"@types/google.maps@^3.55.9": + version "3.55.11" + resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.55.11.tgz#a8227bfc30df54973dc7046b01f8eff5bb9fc63e" + integrity sha512-F3VuPtjKj4UGuyym75pqmgPBOHbT/i7I6/D+4DdtSzbeu2aWZG1ENwpbZOd46uO+PSAz9flJEhxxi+b4MVb4gQ== + "@types/graceful-fs@^4.1.2": version "4.1.6" resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz" @@ -2860,6 +2877,13 @@ expect "^29.0.0" pretty-format "^29.0.0" +"@types/leaflet@^1.9.12": + version "1.9.12" + resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.9.12.tgz#a6626a0b3fba36fd34723d6e95b22e8024781ad6" + integrity sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg== + dependencies: + "@types/geojson" "*" + "@types/node-fetch@^2.6.2": version "2.6.2" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz" @@ -4377,6 +4401,11 @@ entities@^2.0.0: resolved "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== +entities@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz" @@ -4667,6 +4696,11 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" +fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" @@ -4971,6 +5005,15 @@ gzip-size@^6.0.0: dependencies: duplexer "^0.1.2" +happy-dom@^14.12.3: + version "14.12.3" + resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-14.12.3.tgz#1b5892c670461fd1db041bee690981c22d3d521f" + integrity sha512-vsYlEs3E9gLwA1Hp+w3qzu+RUDFf4VTT8cyKqVICoZ2k7WM++Qyd2LwzyTi5bqMJFiIC/vNpTDYuxdreENRK/g== + dependencies: + entities "^4.5.0" + webidl-conversions "^7.0.0" + whatwg-mimetype "^3.0.0" + has-ansi@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz" @@ -6178,6 +6221,11 @@ kleur@^4.1.3, kleur@^4.1.5: resolved "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz" integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== +leaflet@^1.9.4: + version "1.9.4" + resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" + integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA== + leven@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz" @@ -8670,6 +8718,11 @@ webidl-conversions@^6.1.0: resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + whatwg-encoding@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz" @@ -8682,6 +8735,11 @@ whatwg-mimetype@^2.3.0: resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz" integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz"