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 402d4bca253..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 @@ -91,6 +91,10 @@ jobs: 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/package.json b/package.json index 4b93d0b3f66..56e19a771b1 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "private": true, "workspaces": [ - "src/*/assets" + "src/*/assets", + "src/*/src/Bridge/*/assets" ], "scripts": { "build": "node bin/build_javascript.js && node bin/build_styles.js", "test": "bin/run-vitest-all.sh", "lint": "yarn workspaces run eslint src test", - "format": "prettier src/*/assets/src/*.ts src/*/assets/test/*.js {,src/*/}*.{json,md} --write", + "format": "prettier src/*/assets/src/*.ts src/*/assets/test/*.js src/*/src/Bridge/*/assets/src/*.ts src/*/src/Bridge/*/assets/test/*.ts {,src/*/}*.{json,md} --write", "check-lint": "yarn lint --no-fix", "check-format": "yarn format --no-write --check" }, 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/CHANGELOG.md b/src/Map/CHANGELOG.md index 0b528238410..9603bd3c5f1 100644 --- a/src/Map/CHANGELOG.md +++ b/src/Map/CHANGELOG.md @@ -1,5 +1,5 @@ # CHANGELOG -## 2.19.0 +## Unreleased - Component added diff --git a/src/Map/README.md b/src/Map/README.md index 443684f5d84..d554e4163cd 100644 --- a/src/Map/README.md +++ b/src/Map/README.md @@ -3,7 +3,7 @@ **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 GoogleMaps. +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. 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..683b14b1087 --- /dev/null +++ b/src/Map/assets/dist/abstract_map_controller.d.ts @@ -0,0 +1,55 @@ +import { Controller } from '@hotwired/stimulus'; +export type LatLng = { + lat: number; + lng: number; +}; +export type MapView = { + center: LatLng; + zoom: number; + fitBoundsToMarkers: boolean; + markers: Array>; + options: Options; +}; +export type MarkerDefinition = { + position: LatLng; + title: string | null; + infoWindow?: Omit, 'position'>; + rawOptions?: MarkerOptions; +}; +export type InfoWindowDefinition = { + headerContent: string | null; + content: string | null; + position: LatLng; + 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: LatLng; + 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/dist/google_maps_controller.d.ts b/src/Map/assets/dist/google_maps_controller.d.ts deleted file mode 100644 index dcdada40bd8..00000000000 --- a/src/Map/assets/dist/google_maps_controller.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -/// -import { Controller } from '@hotwired/stimulus'; -import type { MapView } from './map'; -type GoogleMapsOptions = Pick; -export default class extends Controller { - static values: { - view: ObjectConstructor; - }; - viewValue: MapView; - private loader; - private map; - private markers; - private infoWindows; - initialize(): void; - connect(): Promise; - private createMarkers; - private createMarker; - private createInfoWindows; - private createInfoWindow; - private createTextOrElement; - private closeInfoWindowsExcept; - private dispatchEvent; -} -export {}; diff --git a/src/Map/assets/dist/google_maps_controller.js b/src/Map/assets/dist/google_maps_controller.js deleted file mode 100644 index 33774d7c48d..00000000000 --- a/src/Map/assets/dist/google_maps_controller.js +++ /dev/null @@ -1,122 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; -import { Loader } from '@googlemaps/js-api-loader'; - -class default_1 extends Controller { - constructor() { - super(...arguments); - this.markers = []; - this.infoWindows = []; - } - initialize() { - var _a, _b; - const providerConfig = (_b = (_a = window.__symfony_ux_maps) === null || _a === void 0 ? void 0 : _a.providers) === null || _b === void 0 ? void 0 : _b['google-maps']; - if (!providerConfig) { - throw new Error('Google Maps provider configuration is missing, did you forget to call `{{ ux_map_script_tags() }}`?'); - } - const loaderOptions = providerConfig; - this.dispatchEvent('init', { - loaderOptions, - }); - this.loader = new Loader(loaderOptions); - } - async connect() { - const { Map: GoogleMap } = await this.loader.importLibrary('maps'); - const { center, zoom, fitBoundsToMarkers, options, markers, infoWindows } = this.viewValue; - this.dispatchEvent('pre-connect', { options }); - this.map = new GoogleMap(this.element, Object.assign(Object.assign({}, options), { center, - zoom })); - this.createMarkers(markers, fitBoundsToMarkers); - this.createInfoWindows(infoWindows); - this.dispatchEvent('connect', { - map: this.map, - markers: this.markers, - infoWindows: this.infoWindows, - }); - } - createMarkers(markers, fitBoundsToMarkers) { - markers.forEach((definition) => this.createMarker(definition)); - if (this.markers.length > 0 && fitBoundsToMarkers) { - const bounds = new google.maps.LatLngBounds(); - this.markers.forEach((marker) => { - if (!marker.position) { - return; - } - bounds.extend(marker.position); - }); - this.map.fitBounds(bounds); - } - } - async createMarker(definition) { - const { AdvancedMarkerElement } = await this.loader.importLibrary('marker'); - const options = { - position: definition.position, - title: definition.title, - }; - this.dispatchEvent('marker:before-create', { options }); - const marker = new AdvancedMarkerElement(Object.assign(Object.assign({}, options), { map: this.map })); - if (definition.infoWindow) { - this.createInfoWindow(definition.infoWindow, marker); - } - this.dispatchEvent('marker:after-create', { marker }); - this.markers.push(marker); - } - createInfoWindows(infoWindows) { - infoWindows.forEach((definition) => this.createInfoWindow(definition)); - } - async createInfoWindow(definition, marker) { - const { InfoWindow } = await this.loader.importLibrary('maps'); - const options = { - headerContent: this.createTextOrElement(definition.headerContent), - content: this.createTextOrElement(definition.content), - position: definition.position, - }; - this.dispatchEvent('info-window:before-create', { options }); - const infoWindow = new InfoWindow(options); - this.infoWindows.push(infoWindow); - if (definition.opened) { - infoWindow.open({ - map: this.map, - shouldFocus: false, - anchor: marker, - }); - } - if (marker) { - marker.addListener('click', () => { - if (definition.autoClose) { - this.closeInfoWindowsExcept(infoWindow); - } - infoWindow.open({ - map: this.map, - anchor: marker, - }); - }); - } - this.dispatchEvent('info-window:after-create', { 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(); - } - }); - } - dispatchEvent(name, payload) { - this.dispatch(name, { detail: payload, prefix: 'google-maps' }); - } -} -default_1.values = { - view: Object, -}; - -export { default_1 as default }; diff --git a/src/Map/assets/dist/leaflet_controller.d.ts b/src/Map/assets/dist/leaflet_controller.d.ts deleted file mode 100644 index e4717cb27f0..00000000000 --- a/src/Map/assets/dist/leaflet_controller.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; -import 'leaflet/dist/leaflet.min.css'; -import type { MapOptions } from 'leaflet'; -import type { MapView } from './map'; -type LeafletOptions = Pick; -type AdditionalOptions = { - tileLayer: { - url: string; - attribution: string; - options: Record; - }; -}; -export default class extends Controller { - static values: { - view: ObjectConstructor; - }; - viewValue: MapView; - private map; - private markers; - private infoWindows; - connect(): void; - private createTileLayer; - private createMarkers; - private createMarker; - private createInfoWindows; - private createInfoWindow; - private dispatchEvent; -} -export {}; diff --git a/src/Map/assets/dist/leaflet_controller.js b/src/Map/assets/dist/leaflet_controller.js deleted file mode 100644 index 7237a585970..00000000000 --- a/src/Map/assets/dist/leaflet_controller.js +++ /dev/null @@ -1,110 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; -import 'leaflet/dist/leaflet.min.css'; -import L from 'leaflet'; - -/****************************************************************************** -Copyright (c) Microsoft Corporation. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. -***************************************************************************** */ - -function __rest(s, e) { - var t = {}; - for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) - t[p] = s[p]; - if (s != null && typeof Object.getOwnPropertySymbols === "function") - for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { - if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) - t[p[i]] = s[p[i]]; - } - return t; -} - -class default_1 extends Controller { - constructor() { - super(...arguments); - this.markers = []; - this.infoWindows = []; - } - connect() { - const { center, zoom, fitBoundsToMarkers, options, markers, infoWindows } = this.viewValue; - this.dispatchEvent('pre-connect', { options }); - const _a = this.viewValue.options, { tileLayer } = _a, mapOptions = __rest(_a, ["tileLayer"]); - this.map = L.map(this.element, Object.assign(Object.assign({}, mapOptions), { center, - zoom })); - this.createTileLayer(tileLayer); - this.createMarkers(markers, fitBoundsToMarkers); - this.createInfoWindows(infoWindows); - this.dispatchEvent('connect', { - map: this.map, - markers: this.markers, - infoWindows: this.infoWindows, - }); - } - createTileLayer(definition) { - const { url, attribution, options } = definition; - L.tileLayer(url, Object.assign({ attribution }, options)).addTo(this.map); - } - createMarkers(markers, fitBoundsToMarkers) { - markers.forEach((definition) => this.createMarker(definition)); - if (fitBoundsToMarkers && this.markers.length > 0) { - this.map.fitBounds(Array.from(this.markers.values()).map((marker) => { - const position = marker.getLatLng(); - return [position.lat, position.lng]; - })); - } - } - createMarker(definition) { - const { position } = definition, options = __rest(definition, ["position"]); - this.dispatchEvent('marker:before-create', { options }); - const marker = L.marker(position, options).addTo(this.map); - if (definition.infoWindow) { - this.createInfoWindow(definition.infoWindow, marker); - } - this.dispatchEvent('marker:after-create', { marker }); - this.markers.push(marker); - } - createInfoWindows(infoWindows) { - infoWindows.forEach((definition) => this.createInfoWindow(definition)); - } - createInfoWindow(definition, marker) { - let infoWindow; - const options = Object.assign({}, definition); - this.dispatchEvent('info-window:before-create', { options }); - const { headerContent, content, position } = options, otherOptions = __rest(options, ["headerContent", "content", "position"]); - if (marker) { - marker.bindPopup(headerContent + '
' + content, otherOptions); - if (definition.opened) { - marker.openPopup(); - } - infoWindow = marker.getPopup(); - } - else { - infoWindow = L.popup(otherOptions) - .setContent(headerContent + '
' + content) - .setLatLng(position); - if (definition.opened) { - infoWindow.openOn(this.map); - } - } - this.infoWindows.push(infoWindow); - this.dispatchEvent('info-window:after-create', { infoWindow }); - } - dispatchEvent(name, payload) { - this.dispatch(name, { detail: payload, prefix: 'leaflet' }); - } -} -default_1.values = { - view: Object, -}; - -export { default_1 as default }; diff --git a/src/Map/assets/package.json b/src/Map/assets/package.json index 14f939319eb..2561ef55ef4 100644 --- a/src/Map/assets/package.json +++ b/src/Map/assets/package.json @@ -3,48 +3,19 @@ "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": { - "controllers": { - "google-maps": { - "main": "dist/google_maps_controller.js", - "webpackMode": "lazy", - "fetch": "lazy", - "enabled": false - }, - "leaflet": { - "main": "dist/leaflet_controller.js", - "webpackMode": "lazy", - "fetch": "lazy", - "enabled": false - } - }, "importmap": { "@hotwired/stimulus": "^3.0.0", - "@googlemaps/js-api-loader": "^1.16.6", - "leaflet": "^1.9.4", - "@symfony/ux-map/google-maps": "path:%PACKAGE%/dist/google_maps_controller.js", - "@symfony/ux-map/leaflet": "path:%PACKAGE%/dist/leaflet_controller.js" + "@symfony/ux-map/abstract-map-controller": "path:%PACKAGE%/dist/abstract_map_controller.js" } }, "peerDependencies": { - "@googlemaps/js-api-loader": "^1.16.6", - "@hotwired/stimulus": "^3.0.0", - "leaflet": "^1.9.4" - }, - "peerDependenciesMeta": { - "@googlemaps/js-api-loader": { - "optional": true - }, - "leaflet": { - "optional": true - } + "@hotwired/stimulus": "^3.0.0" }, "devDependencies": { - "@googlemaps/js-api-loader": "^1.16.6", - "@hotwired/stimulus": "^3.0.0", - "@types/google.maps": "^3.55.9", - "@types/leaflet": "^1.9.12", - "happy-dom": "^14.12.3", - "leaflet": "^1.9.4" + "@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..dfaca430717 --- /dev/null +++ b/src/Map/assets/src/abstract_map_controller.ts @@ -0,0 +1,121 @@ +import { Controller } from '@hotwired/stimulus'; + +export type LatLng = { lat: number; lng: number }; + +export type MapView = { + center: LatLng; + zoom: number; + fitBoundsToMarkers: boolean; + markers: Array>; + options: Options; +}; + +export type MarkerDefinition = { + position: LatLng; + title: string | null; + infoWindow?: Omit, 'position'>; + rawOptions?: MarkerOptions; +}; + +export type InfoWindowDefinition = { + headerContent: string | null; + content: string | null; + position: LatLng; + 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: LatLng; + 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/src/global.d.ts b/src/Map/assets/src/global.d.ts deleted file mode 100644 index 2a0838fbe90..00000000000 --- a/src/Map/assets/src/global.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { LoaderOptions } from '@googlemaps/js-api-loader'; - -declare global { - interface Window { - __symfony_ux_maps?: { - providers?: { - 'google-maps'?: LoaderOptions; - leaflet?: Record; - }; - }; - } -} diff --git a/src/Map/assets/src/google_maps_controller.ts b/src/Map/assets/src/google_maps_controller.ts deleted file mode 100644 index 7c11c4b7c0b..00000000000 --- a/src/Map/assets/src/google_maps_controller.ts +++ /dev/null @@ -1,197 +0,0 @@ -/* - * 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 { Controller } from '@hotwired/stimulus'; -import { Loader } from '@googlemaps/js-api-loader'; -import type { InfoWindowDefinition, MapView, MarkerDefinition } from './map'; - -type GoogleMapsOptions = Pick< - google.maps.MapOptions, - | 'mapId' - | 'gestureHandling' - | 'backgroundColor' - | 'disableDoubleClickZoom' - | 'zoomControl' - | 'zoomControlOptions' - | 'mapTypeControl' - | 'mapTypeControlOptions' - | 'streetViewControl' - | 'streetViewControlOptions' - | 'fullscreenControl' - | 'fullscreenControlOptions' ->; - -export default class extends Controller { - static values = { - view: Object, - }; - - declare viewValue: MapView; - - private loader: Loader; - private map: google.maps.Map; - private markers: Array = []; - private infoWindows: Array = []; - - initialize() { - const providerConfig = window.__symfony_ux_maps?.providers?.['google-maps']; - if (!providerConfig) { - throw new Error( - 'Google Maps provider configuration is missing, did you forget to call `{{ ux_map_script_tags() }}`?' - ); - } - - const loaderOptions = providerConfig; - - this.dispatchEvent('init', { - loaderOptions, - }); - - this.loader = new Loader(loaderOptions); - } - - async connect() { - const { Map: GoogleMap } = await this.loader.importLibrary('maps'); - const { center, zoom, fitBoundsToMarkers, options, markers, infoWindows } = this.viewValue; - - this.dispatchEvent('pre-connect', { options }); - - this.map = new GoogleMap(this.element, { - ...options, - center, - zoom, - }); - this.createMarkers(markers, fitBoundsToMarkers); - this.createInfoWindows(infoWindows); - - this.dispatchEvent('connect', { - map: this.map, - markers: this.markers, - infoWindows: this.infoWindows, - }); - } - - private createMarkers(markers: Array, fitBoundsToMarkers: boolean) { - markers.forEach((definition) => this.createMarker(definition)); - - if (this.markers.length > 0 && fitBoundsToMarkers) { - const bounds = new google.maps.LatLngBounds(); - this.markers.forEach((marker) => { - if (!marker.position) { - return; - } - - bounds.extend(marker.position); - }); - this.map.fitBounds(bounds); - } - } - - private async createMarker(definition: MarkerDefinition) { - // Load the marker library on demand. Doing it twice won't make another HTTP request. - const { AdvancedMarkerElement } = await this.loader.importLibrary('marker'); - - const options = { - position: definition.position, - title: definition.title, - }; - this.dispatchEvent('marker:before-create', { options }); - - const marker = new AdvancedMarkerElement({ - ...options, - map: this.map, - }); - - if (definition.infoWindow) { - this.createInfoWindow(definition.infoWindow, marker); - } - - this.dispatchEvent('marker:after-create', { marker }); - - this.markers.push(marker); - } - - private createInfoWindows(infoWindows: Array) { - infoWindows.forEach((definition) => this.createInfoWindow(definition)); - } - - private async createInfoWindow(definition: InfoWindowDefinition): Promise; - private async createInfoWindow( - definition: MarkerDefinition['infoWindow'], - marker: google.maps.marker.AdvancedMarkerElement - ): Promise; - private async createInfoWindow( - definition: InfoWindowDefinition, - marker?: google.maps.marker.AdvancedMarkerElement - ): Promise { - // Load the marker library on demand. Doing it twice won't make another HTTP request. - const { InfoWindow } = await this.loader.importLibrary('maps'); - const options = { - headerContent: this.createTextOrElement(definition.headerContent), - content: this.createTextOrElement(definition.content), - position: definition.position, - }; - - this.dispatchEvent('info-window:before-create', { options }); - - const infoWindow = new InfoWindow(options); - - this.infoWindows.push(infoWindow); - - if (definition.opened) { - infoWindow.open({ - map: this.map, - shouldFocus: false, - anchor: marker, - }); - } - - if (marker) { - marker.addListener('click', () => { - if (definition.autoClose) { - this.closeInfoWindowsExcept(infoWindow); - } - - infoWindow.open({ - map: this.map, - anchor: marker, - }); - }); - } - - this.dispatchEvent('info-window:after-create', { 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(); - } - }); - } - - private dispatchEvent(name: string, payload: any) { - this.dispatch(name, { detail: payload, prefix: 'google-maps' }); - } -} diff --git a/src/Map/assets/src/leaflet_controller.ts b/src/Map/assets/src/leaflet_controller.ts deleted file mode 100644 index 8bbb4c2db42..00000000000 --- a/src/Map/assets/src/leaflet_controller.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* - * 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. - */ - -'use strict'; - -import { Controller } from '@hotwired/stimulus'; -import 'leaflet/dist/leaflet.min.css'; -import type { Map as LeafletMap, MapOptions, Marker, Popup } from 'leaflet'; -import L from 'leaflet'; -import type { InfoWindowDefinition, MapView, MarkerDefinition } from './map'; - -type LeafletOptions = Pick; -type AdditionalOptions = { - tileLayer: { url: string; attribution: string; options: Record }; -}; - -export default class extends Controller { - static values = { - view: Object, - }; - - declare viewValue: MapView; - - private map: LeafletMap; - private markers: Array = []; - private infoWindows: Array = []; - - connect() { - const { center, zoom, fitBoundsToMarkers, options, markers, infoWindows } = this.viewValue; - - this.dispatchEvent('pre-connect', { options }); - - const { tileLayer, ...mapOptions } = this.viewValue.options; - - this.map = L.map(this.element, { - ...mapOptions, - center, - zoom, - }); - this.createTileLayer(tileLayer); - this.createMarkers(markers, fitBoundsToMarkers); - this.createInfoWindows(infoWindows); - - this.dispatchEvent('connect', { - map: this.map, - markers: this.markers, - infoWindows: this.infoWindows, - }); - } - - private createTileLayer(definition: AdditionalOptions['tileLayer']) { - const { url, attribution, options } = definition; - - L.tileLayer(url, { - attribution, - ...options, - }).addTo(this.map); - } - - private createMarkers(markers: Array, fitBoundsToMarkers: boolean) { - markers.forEach((definition) => this.createMarker(definition)); - - if (fitBoundsToMarkers && this.markers.length > 0) { - this.map.fitBounds( - Array.from(this.markers.values()).map((marker) => { - const position = marker.getLatLng(); - - return [position.lat, position.lng]; - }) - ); - } - } - - private createMarker(definition: MarkerDefinition) { - const { position, ...options } = definition; - - this.dispatchEvent('marker:before-create', { options }); - - const marker = L.marker(position, options).addTo(this.map); - - if (definition.infoWindow) { - this.createInfoWindow(definition.infoWindow, marker); - } - - this.dispatchEvent('marker:after-create', { marker }); - - this.markers.push(marker); - } - - private createInfoWindows(infoWindows: Array) { - infoWindows.forEach((definition) => this.createInfoWindow(definition)); - } - - private createInfoWindow(definition: InfoWindowDefinition): void; - private createInfoWindow(definition: MarkerDefinition['infoWindow'], marker: Marker): void; - private createInfoWindow(definition: InfoWindowDefinition, marker?: Marker): void { - let infoWindow: Popup; - const options = { ...definition }; - - this.dispatchEvent('info-window:before-create', { options }); - - const { headerContent, content, position, ...otherOptions } = options; - - if (marker) { - marker.bindPopup(headerContent + '
' + content, otherOptions); - if (definition.opened) { - marker.openPopup(); - } - - infoWindow = marker.getPopup()!; - } else { - infoWindow = L.popup(otherOptions) - .setContent(headerContent + '
' + content) - .setLatLng(position); - - if (definition.opened) { - infoWindow.openOn(this.map); - } - } - - this.infoWindows.push(infoWindow); - - this.dispatchEvent('info-window:after-create', { infoWindow }); - } - - private dispatchEvent(name: string, payload: any) { - this.dispatch(name, { detail: payload, prefix: 'leaflet' }); - } -} diff --git a/src/Map/assets/src/map.d.ts b/src/Map/assets/src/map.d.ts deleted file mode 100644 index 98df8cddcad..00000000000 --- a/src/Map/assets/src/map.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -type LatLng = { lat: number; lng: number }; - -export type MapView = { - center: LatLng; - zoom: number; - fitBoundsToMarkers: boolean; - options: Options; - markers: Array; - infoWindows: Array; -}; - -export type MarkerDefinition = { - position: LatLng; - title: string | null; - infoWindow?: Omit; -}; - -export type InfoWindowDefinition = { - headerContent: string | null; - content: string | null; - position: LatLng; - opened: boolean; - autoClose: boolean; -}; 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..3dbe6743eaa --- /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; + + 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/assets/test/google_maps_controller.test.ts b/src/Map/assets/test/google_maps_controller.test.ts deleted file mode 100644 index efe6b9ef1ee..00000000000 --- a/src/Map/assets/test/google_maps_controller.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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 GoogleMapsController from '../src/google_maps_controller'; - -// Controller used to check the actual controller was properly booted -class CheckController extends Controller { - connect() { - this.element.addEventListener('google-maps:pre-connect', (event) => { - this.element.classList.add('pre-connected'); - }); - - this.element.addEventListener('google-maps:connect', (event) => { - this.element.classList.add('connected'); - }); - } -} - -const startStimulus = () => { - const application = Application.start(); - application.register('check', CheckController); - application.register('google-maps', GoogleMapsController); -}; - -describe('GoogleMapsController', () => { - let container; - - beforeEach(() => { - container = mountDOM(` -
- `); - }); - - afterEach(() => { - clearDOM(); - }); - - it('connect' , async () => { - window.__symfony_ux_maps = { - providers: { - ['google-maps']: { - key: '', - }, - }, - } - - 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/assets/test/leaflet_controller.test.ts b/src/Map/assets/test/leaflet_controller.test.ts deleted file mode 100644 index 3812a995f57..00000000000 --- a/src/Map/assets/test/leaflet_controller.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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/leaflet_controller'; - -// Controller used to check the actual controller was properly booted -class CheckController extends Controller { - connect() { - this.element.addEventListener('leaflet:pre-connect', (event) => { - this.element.classList.add('pre-connected'); - }); - - this.element.addEventListener('leaflet: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; - - beforeEach(() => { - container = mountDOM(` -
- `); - }); - - afterEach(() => { - clearDOM(); - }); - - it('connect' , async () => { - window.__symfony_ux_maps = { - providers: { - leaflet: {}, - }, - } - - 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/composer.json b/src/Map/composer.json index ace65bf6ec3..73d39303f74 100644 --- a/src/Map/composer.json +++ b/src/Map/composer.json @@ -23,7 +23,8 @@ "autoload": { "psr-4": { "Symfony\\UX\\Map\\": "src/" - } + }, + "exclude-from-classmap": [] }, "autoload-dev": { "psr-4": { @@ -32,14 +33,13 @@ }, "require": { "php": ">=8.3", - "symfony/stimulus-bundle": "^2.18.12" + "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", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/twig-bundle": "^6.4|^7.0" }, "extra": { "thanks": { diff --git a/src/Map/config/asset_mapper.php b/src/Map/config/asset_mapper.php deleted file mode 100644 index 7ccdee78973..00000000000 --- a/src/Map/config/asset_mapper.php +++ /dev/null @@ -1,36 +0,0 @@ - - * - * 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\AssetMapper\ImportMap\Compiler\LeafletReplaceImagesAssetCompiler; -use Symfony\UX\Map\AssetMapper\ImportMap\Resolver\LeafletPackageResolver; - -/* - * @author Hugo Alliaume - */ -return static function (ContainerConfigurator $container): void { - $container->services() - ->set('ux_map.asset_mapper.leaflet_replace_images_compiler', LeafletReplaceImagesAssetCompiler::class) - ->args([ - service('logger'), - ]) - ->tag('asset_mapper.compiler') - ->tag('monolog.logger', ['channel' => 'asset_mapper']) - - ->set('ux_map.asset_mapper.importmap.resolver.leaflet_package_resolver', LeafletPackageResolver::class) - ->args([ - service('.inner'), - service('http_client')->nullOnInvalid(), - ]) - ->decorate('asset_mapper.importmap.resolver') - ; -}; diff --git a/src/Map/config/services.php b/src/Map/config/services.php index a6a5ad3f6c7..7dada5ec563 100644 --- a/src/Map/config/services.php +++ b/src/Map/config/services.php @@ -11,54 +11,35 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; -use Symfony\UX\Map\MapFactory; -use Symfony\UX\Map\MapRegistry; -use Symfony\UX\Map\Provider\GoogleMaps\GoogleMapsProviderFactory; -use Symfony\UX\Map\Provider\Leaflet\LeafletProviderFactory; -use Symfony\UX\Map\Provider\Provider; -use Symfony\UX\Map\Provider\ProviderInterface; +use Symfony\UX\Map\Renderer\AbstractRendererFactory; +use Symfony\UX\Map\Renderer\Renderer; +use Symfony\UX\Map\Renderer\Renderers; use Symfony\UX\Map\Twig\MapExtension; -use Symfony\UX\Map\Twig\MapRuntime; /* * @author Hugo Alliaume */ return static function (ContainerConfigurator $container): void { $container->services() - ->set('ux_map.map_factory', MapFactory::class) + ->set('ux_map.renderers', Renderers::class) + ->factory([service('ux_map.renderer_factory'), 'fromStrings']) ->args([ - service('ux_map.default_provider'), - service('ux_map.map_registry'), + abstract_arg('renderers configuration'), ]) - ->alias(MapFactory::class, 'ux_map.map_factory') + ->tag('twig.runtime') - ->set('ux_map.default_provider', ProviderInterface::class) - ->factory([service('ux_map.provider'), 'fromString']) + ->set('ux_map.renderer_factory.abstract', AbstractRendererFactory::class) + ->abstract() ->args([ - abstract_arg('provider configuration'), + service('stimulus.helper'), ]) - ->set('ux_map.provider', Provider::class) + ->set('ux_map.renderer_factory', Renderer::class) ->args([ - tagged_iterator('ux_map.provider_factory', indexAttribute: 'name'), + tagged_iterator('ux_map.renderer_factory'), ]) - ->set('ux_map.map_registry', MapRegistry::class) - ->set('ux_map.twig_extension', MapExtension::class) ->tag('twig.extension') - - ->set('ux_map.twig_runtime', MapRuntime::class) - ->args([ - service('stimulus.helper'), - service('ux_map.map_registry'), - ]) - ->tag('twig.runtime') - - ->set('ux_map.google.provider_factory', GoogleMapsProviderFactory::class) - ->tag('ux_map.provider_factory', ['name' => 'google']) - - ->set('ux_map.leaflet.provider_factory', LeafletProviderFactory::class) - ->tag('ux_map.provider_factory', ['name' => 'leaflet']) ; }; diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst index 0d361ba5907..b60074e5ee8 100644 --- a/src/Map/doc/index.rst +++ b/src/Map/doc/index.rst @@ -28,19 +28,6 @@ needed if you're using AssetMapper): $ npm install --force $ npm run watch - # or use yarn - $ yarn install --force - $ yarn watch - -After installing the bundle, ensure the line ``{{ ux_map_script_tags() }}`` is present in your Twig template, e.g.: - -.. code-block:: twig - - {% block javascripts %} - {% block importmap %}{{ importmap('app') }}{% endblock %} - {{ ux_map_script_tags() }} - {% endblock %} - Configuration ------------- @@ -50,10 +37,21 @@ Configuration is done in your ``config/packages/ux_map.yaml`` file: # config/packages/ux_map.yaml ux_map: - provider: '%env(UX_MAP_DSN)%' + renderer: '%env(UX_MAP_DSN)%' -The ``UX_MAP_DSN`` environment variable should contain the provider DSN to use, e.g. ``google-maps://`` or ``leaflet://``. -See :ref:`Map providers` for more information on the available providers. +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 ----- @@ -61,8 +59,7 @@ Usage Creating and rendering ~~~~~~~~~~~~~~~~~~~~~~ -To create a map, you must use the ``MapFactory`` service. -This service allows you to create a new ``Map`` instance by using the default configured provider:: +A map is created by calling ``new Map()``. You can configure the center, zoom, and add markers:: namespace App\Controller; @@ -71,25 +68,21 @@ This service allows you to create a new ``Map`` instance by using the default co use Symfony\Component\Routing\Attribute\Route; use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\LatLng; - use Symfony\UX\Map\MapFactory; + use Symfony\UX\Map\Map; use Symfony\UX\Map\Marker; - final class ContactController extends AbstractController + final class HomeController extends AbstractController { - #[Route('/contact')] - public function __invoke(MapFactory $mapFactory): Response + #[Route('/')] + public function __invoke(): Response { // 1. Create a new map instance - $myMap = $mapFactory->createMap(); - - // 2. The map can be programmatically configured with a fluent API, you can change the center, zoom, and other options specific to the provider - $myMap + $myMap = (new Map()); ->center(new LatLng(46.903354, 1.888334)) ->zoom(6) - ->fitBoundsToMarkers() ; - // 3. You can also add markers + // 2. You can add markers, with an optional info window $myMap ->addMarker(new Marker( position: new LatLng(48.8566, 2.3522), @@ -105,7 +98,7 @@ This service allows you to create a new ``Map`` instance by using the default co ) )); - // 4. and then, you must inject the map in your template to render it + // 3. And inject the map in your template to render it return $this->render('contact/index.html.twig', [ 'my_map' => $myMap, ]); @@ -119,144 +112,87 @@ To render a map in your Twig template, use the ``render_map`` Twig function, e.g {{ render_map(my_map) }} {# or with custom attributes #} - {{ render_map(my_map, { 'data-controller': 'my-map', style: 'height: 300px' }) }} + {{ render_map(my_map, { style: 'height: 300px' }) }} -Map providers -------------- +Extend the default behavior +~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Leaflet -~~~~~~~ +Symfony UX Map allows you to extend its default behavior using a custom Stimulus controller: -You can use `Leaflet`_ as the default provider by configuring the ``UX_MAP_DSN`` environment variable like this: +.. code-block:: javascript -.. code-block:: env - - # .env - UX_MAP_DSN=leaflet://default - -Then, enable the Leaflet`_ Stimulus controller in your ``assets/controllers.json``: - -.. code-block:: diff + // assets/controllers/mymap_controller.js - { - "controllers": { - "@symfony/ux-map": { - "leaflet": { - - "enabled": false, - + "enabled": true, - "fetch": "lazy" - } - }, - }, - "entrypoints": [] - } - -You can configure the map with specific Leaflet options by passing a ``LeafletOptions`` instance to the ``options`` method:: - - use Symfony\UX\Map\Provider\Leaflet\LeafletOptions; - use Symfony\UX\Map\Provider\Leaflet\Option as LeafletOption; - - $map - ->options(new LeafletOptions( - tileLayer: new LeafletOption\TileLayer( - url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - attribution: '© OpenStreetMap contributors', - options: [ - 'maxZoom' => 19, - ] - ), - )); - -Google Maps -~~~~~~~~~~~ - -You can use `Google Maps`_ as the default provider by configuring the ``UX_MAP_DSN`` environment variable like this: - -.. code-block:: env - - # .env - UX_MAP_DSN=google-maps://GOOGLE_MAPS_API_KEY@default - UX_MAP_DSN=google-maps://GOOGLE_MAPS_API_KEY@default?version=weekly - UX_MAP_DSN=google-maps://GOOGLE_MAPS_API_KEY@default?language=fr®ion=FR - -The provider has a number of options: - -====================== ====================================== =================================== - Option Description Default -====================== ====================================== =================================== -``id`` The id of the script tag __googleMapsScriptId -``language`` Force language, see The user's preferred language - `list of supported languages`_ specified in the browser -``region`` Unicode region subtag identifiers - compatible with `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 https://maps.googleapis.com/maps/api/js - script -``version`` The release channels or version weekly - numbers -====================== ====================================== =================================== - -Then, enable the `Google Maps`_ Stimulus controller in your ``assets/controllers.json``: - -.. code-block:: diff + import { Controller } from '@hotwired/stimulus'; - { - "controllers": { - "@symfony/ux-map": { - "google-maps": { - - "enabled": false, - + "enabled": true, - "fetch": "lazy" - } - }, - }, - "entrypoints": [] + 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); + } } -You can configure the map with specific Google Maps options by passing a ``GoogleOptions`` instance to the ``options``:: - - use Symfony\UX\Map\Provider\GoogleMaps\GoogleMapsOptions; - use Symfony\UX\Map\Provider\GoogleMaps\Option as GoogleMapsOption; - - $map - ->options(new GoogleMapsOptions( - mapId: '2b2d73ba4b8c7b41', // Enable markers and Google Maps Cloud Styles (https://developers.google.com/maps/documentation/cloud-customization) - zoomControlOptions: new GoogleMapsOption\ZoomControlOptions( - position: GoogleMapsOption\ControlPosition::BLOCK_START_INLINE_END, - ), - mapTypeControl: false, - streetViewControl: false, - fullscreenControlOptions: new GoogleMapsOption\FullscreenControlOptions( - position: GoogleMapsOption\ControlPosition::BLOCK_START_INLINE_START, - ), - )); - -.. _using-with-asset-mapper: - -Using with AssetMapper ----------------------- - -Using this library with AssetMapper is possible. - -When installing with AssetMapper, Flex will add a few new items to your ``importmap.php``:: - - '@symfony/ux-map/google-maps' => [ - 'path' => '@symfony/ux-map/google_maps_controller.js', - ], - '@symfony/ux-map/leaflet' => [ - 'path' => '@symfony/ux-map/leaflet_controller.js', - ], - '@googlemaps/js-api-loader' => [ - 'version' => '1.16.6', - ], - 'leaflet' => [ - 'version' => '1.9.4', - ], - 'leaflet/dist/leaflet.min.css' => [ - 'version' => '1.9.4', - 'type' => 'css', - ], +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 ------------------------------ @@ -267,7 +203,5 @@ 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 -.. _`Leaflet`: https://leafletjs.com -.. _`Google Maps`: https://developers.google.com/maps/documentation/javascript/overview -.. _`ISO 3166-1`: https://en.wikipedia.org/wiki/ISO_3166-1 -.. _`list of supported languages`: https://developers.google.com/maps/faq#languagesupport +.. _`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/src/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompiler.php b/src/Map/src/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompiler.php deleted file mode 100644 index f7823dfef4b..00000000000 --- a/src/Map/src/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompiler.php +++ /dev/null @@ -1,68 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\AssetMapper\ImportMap\Compiler; - -use Psr\Log\LoggerInterface; -use Symfony\Component\AssetMapper\AssetMapperInterface; -use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface; -use Symfony\Component\AssetMapper\Exception\RuntimeException; -use Symfony\Component\AssetMapper\MappedAsset; -use Symfony\Component\Filesystem\Path; - -/** - * Replaces the image paths in the Leaflet library JavaScript code with their public path. - * - * @internal - * - * @author Hugo Alliaume - */ -final class LeafletReplaceImagesAssetCompiler implements AssetCompilerInterface -{ - /** - * https://regex101.com/r/n3fSEN/1. - */ - public const ASSETS_PATTERN = '/"(?P[^"]+?\.png)"/'; - - public function __construct( - private readonly ?LoggerInterface $logger = null, - ) { - } - - public function supports(MappedAsset $asset): bool - { - return str_ends_with($asset->sourcePath, 'vendor/leaflet/leaflet.index.js'); - } - - public function compile(string $content, MappedAsset $asset, AssetMapperInterface $assetMapper): string - { - return preg_replace_callback(self::ASSETS_PATTERN, function ($matches) use ($asset, $assetMapper) { - try { - $resolvedSourcePath = Path::join(\dirname($asset->sourcePath), 'dist', 'images', $matches['asset']); - } catch (RuntimeException $e) { - $this->logger?->warning(\sprintf('Error processing import in "%s": ', $asset->sourcePath).$e->getMessage()); - - return $matches[0]; - } - - $dependentAsset = $assetMapper->getAssetFromSourcePath($resolvedSourcePath); - - if (null === $dependentAsset) { - return $matches[0]; - } - - $asset->addDependency($dependentAsset); - $relativePath = $dependentAsset->publicPath; - - return "\"$relativePath\""; - }, $content); - } -} diff --git a/src/Map/src/AssetMapper/ImportMap/Resolver/LeafletPackageResolver.php b/src/Map/src/AssetMapper/ImportMap/Resolver/LeafletPackageResolver.php deleted file mode 100644 index 4ecb4ba85b9..00000000000 --- a/src/Map/src/AssetMapper/ImportMap/Resolver/LeafletPackageResolver.php +++ /dev/null @@ -1,74 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\AssetMapper\ImportMap\Resolver; - -use Symfony\Component\AssetMapper\ImportMap\Resolver\PackageResolverInterface; -use Symfony\Component\Filesystem\Path; -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\UX\Map\AssetMapper\ImportMap\Compiler\LeafletReplaceImagesAssetCompiler; - -/** - * PackageResolver decorator for Leaflet. - * - * Some files mentioned in Leaflet's JavaScript code could not be detected as extra files by the actual PackageResolver. - * Without this decorator, in the following code, the .png files won't be detected as extra files: - * - * // ... - * $i.extend({options:{iconUrl:"marker-icon.png",iconRetinaUrl:"marker-icon-2x.png",shadowUrl:"marker-shadow.png", - * // ... - * - * @internal - * - * @author Hugo Alliaume - */ -class LeafletPackageResolver implements PackageResolverInterface -{ - public function __construct( - private PackageResolverInterface $inner, - private ?HttpClientInterface $httpClient, - ) { - } - - public function resolvePackages(array $packagesToRequire): array - { - return $this->inner->resolvePackages($packagesToRequire); - } - - public function downloadPackages(array $importMapEntries, ?callable $progressCallback = null): array - { - $contents = $this->inner->downloadPackages($importMapEntries, $progressCallback); - - if (isset($contents['leaflet'])) { - $this->httpClient ??= HttpClient::create(); - $responses = []; - - preg_match_all(LeafletReplaceImagesAssetCompiler::ASSETS_PATTERN, $contents['leaflet']['content'], $leafletAssets); - - foreach ($leafletAssets['asset'] as $leafletAsset) { - $distPath = Path::join('dist', 'images', $leafletAsset); - $responses[] = $this->httpClient->request( - 'GET', - \sprintf('https://cdn.jsdelivr.net/npm/leaflet@%s/%s', $importMapEntries['leaflet']->version, $distPath), - ['user_data' => ['dist_path' => $distPath]] - ); - } - - foreach ($responses as $response) { - $distPath = $response->getInfo('user_data')['dist_path']; - $contents['leaflet']['extraFiles'][$distPath] = $response->getContent(); - } - } - - return $contents; - } -} 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..a98037d2fc4 --- /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\LatLng; +use Symfony\UX\Map\Map; + +$map = (new Map()) + ->center(new LatLng(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..787fb740026 --- /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 { LatLng, 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: LatLng; + 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..a18243125bd --- /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, 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..d176ff9184a --- /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 { LatLng, 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, InfoWindow } = await loader.importLibrary('maps'); + const { AdvancedMarkerElement } = await loader.importLibrary('marker'); + library = { Map, AdvancedMarkerElement, InfoWindow }; + + super.connect(); + } + + protected doCreateMap({ + center, + zoom, + options, + }: { + center: LatLng; + 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..aaa0e8bd12f --- /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; + + 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/assets/vitest.config.js b/src/Map/src/Bridge/Google/assets/vitest.config.js similarity index 64% rename from src/Map/assets/vitest.config.js rename to src/Map/src/Bridge/Google/assets/vitest.config.js index 4b46e02732e..3892eefac50 100644 --- a/src/Map/assets/vitest.config.js +++ b/src/Map/src/Bridge/Google/assets/vitest.config.js @@ -1,12 +1,12 @@ import { defineConfig, mergeConfig } from 'vitest/config'; -import configShared from '../../../vitest.config.js' +import configShared from '../../../../../../vitest.config.js' export default mergeConfig( configShared, defineConfig({ resolve: { alias: { - 'leaflet/dist/leaflet.min.css': require.resolve('leaflet/dist/leaflet.css'), + '@symfony/ux-map/abstract-map-controller': __dirname + '/../../../../assets/src/abstract_map_controller.ts', }, }, test: { 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/Provider/GoogleMaps/GoogleMapsOptions.php b/src/Map/src/Bridge/Google/src/GoogleOptions.php similarity index 74% rename from src/Map/src/Provider/GoogleMaps/GoogleMapsOptions.php rename to src/Map/src/Bridge/Google/src/GoogleOptions.php index 9972f145746..8b26efcfba9 100644 --- a/src/Map/src/Provider/GoogleMaps/GoogleMapsOptions.php +++ b/src/Map/src/Bridge/Google/src/GoogleOptions.php @@ -9,23 +9,23 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\GoogleMaps; +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; -use Symfony\UX\Map\Provider\GoogleMaps\Option\FullscreenControlOptions; -use Symfony\UX\Map\Provider\GoogleMaps\Option\GestureHandling; -use Symfony\UX\Map\Provider\GoogleMaps\Option\MapTypeControlOptions; -use Symfony\UX\Map\Provider\GoogleMaps\Option\StreetViewControlOptions; -use Symfony\UX\Map\Provider\GoogleMaps\Option\ZoomControlOptions; /** * @author Hugo Alliaume */ -final class GoogleMapsOptions implements MapOptionsInterface +final class GoogleOptions implements MapOptionsInterface { public function __construct( private ?string $mapId = null, - private GestureHandling $gestureHandling = GestureHandling::Auto, + private GestureHandling $gestureHandling = GestureHandling::AUTO, private ?string $backgroundColor = null, private bool $disableDoubleClickZoom = false, private bool $zoomControl = true, @@ -76,6 +76,7 @@ public function zoomControl(bool $enable = true): self public function zoomControlOptions(ZoomControlOptions $zoomControlOptions): self { + $this->zoomControl = true; $this->zoomControlOptions = $zoomControlOptions; return $this; @@ -90,6 +91,7 @@ public function mapTypeControl(bool $enable = true): self public function mapTypeControlOptions(MapTypeControlOptions $mapTypeControlOptions): self { + $this->mapTypeControl = true; $this->mapTypeControlOptions = $mapTypeControlOptions; return $this; @@ -104,6 +106,7 @@ public function streetViewControl(bool $enable = true): self public function streetViewControlOptions(StreetViewControlOptions $streetViewControlOptions): self { + $this->streetViewControl = true; $this->streetViewControlOptions = $streetViewControlOptions; return $this; @@ -118,6 +121,7 @@ public function fullscreenControl(bool $enable = true): self public function fullscreenControlOptions(FullscreenControlOptions $fullscreenControlOptions): self { + $this->fullscreenControl = true; $this->fullscreenControlOptions = $fullscreenControlOptions; return $this; @@ -125,19 +129,29 @@ public function fullscreenControlOptions(FullscreenControlOptions $fullscreenCon public function toArray(): array { - return [ + $array = [ 'mapId' => $this->mapId, 'gestureHandling' => $this->gestureHandling->value, 'backgroundColor' => $this->backgroundColor, 'disableDoubleClickZoom' => $this->disableDoubleClickZoom, - 'zoomControl' => $this->zoomControl, - 'zoomControlOptions' => $this->zoomControlOptions->toArray(), - 'mapTypeControl' => $this->mapTypeControl, - 'mapTypeControlOptions' => $this->mapTypeControlOptions->toArray(), - 'streetViewControl' => $this->streetViewControl, - 'streetViewControlOptions' => $this->streetViewControlOptions->toArray(), - 'fullscreenControl' => $this->fullscreenControl, - 'fullscreenControlOptions' => $this->fullscreenControlOptions->toArray(), ]; + + 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/Provider/GoogleMaps/Option/FullscreenControlOptions.php b/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php similarity index 78% rename from src/Map/src/Provider/GoogleMaps/Option/FullscreenControlOptions.php rename to src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php index 8a8f8d1c3f1..35256551f23 100644 --- a/src/Map/src/Provider/GoogleMaps/Option/FullscreenControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\GoogleMaps\Option; +namespace Symfony\UX\Map\Bridge\Google\Option; /** * Options for the rendering of the fullscreen control. @@ -18,10 +18,10 @@ * * @author Hugo Alliaume */ -final class FullscreenControlOptions +final readonly class FullscreenControlOptions { public function __construct( - public ControlPosition $position = ControlPosition::INLINE_END_BLOCK_START, + private ControlPosition $position = ControlPosition::INLINE_END_BLOCK_START, ) { } diff --git a/src/Map/src/Provider/GoogleMaps/Option/GestureHandling.php b/src/Map/src/Bridge/Google/src/Option/GestureHandling.php similarity index 76% rename from src/Map/src/Provider/GoogleMaps/Option/GestureHandling.php rename to src/Map/src/Bridge/Google/src/Option/GestureHandling.php index 9dcb00b48bd..ac8a354e30f 100644 --- a/src/Map/src/Provider/GoogleMaps/Option/GestureHandling.php +++ b/src/Map/src/Bridge/Google/src/Option/GestureHandling.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\GoogleMaps\Option; +namespace Symfony\UX\Map\Bridge\Google\Option; /** * This setting controls how the API handles gestures on the map. @@ -26,20 +26,20 @@ enum GestureHandling: string * Scroll events with a ctrl key or ⌘ key pressed zoom the map. * In this mode the map cooperates with the page. */ - case Cooperative = 'cooperative'; + case COOPERATIVE = 'cooperative'; /** * All touch gestures and scroll events pan or zoom the map. */ - case Greedy = 'greedy'; + case GREEDY = 'greedy'; /** * The map cannot be panned or zoomed by user gestures. */ - case None = 'none'; + case NONE = 'none'; /** - * (default) Gesture handling is either cooperative or greedy, depending on whether the page is scrollable or in an iframe. + * Gesture handling is either cooperative or greedy, depending on whether the page is scrollable or in an iframe. */ - case Auto = 'auto'; + case AUTO = 'auto'; } diff --git a/src/Map/src/Provider/GoogleMaps/Option/MapTypeControlOptions.php b/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php similarity index 56% rename from src/Map/src/Provider/GoogleMaps/Option/MapTypeControlOptions.php rename to src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php index 2c4036ef49e..99e1fba1fb7 100644 --- a/src/Map/src/Provider/GoogleMaps/Option/MapTypeControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\GoogleMaps\Option; +namespace Symfony\UX\Map\Bridge\Google\Option; /** * Options for the rendering of the map type control. @@ -18,25 +18,22 @@ * * @author Hugo Alliaume */ -final class MapTypeControlOptions +final readonly class MapTypeControlOptions { /** - * @param array $mapTypeIds + * @param array<'hybrid'|'roadmap'|'satellite'|'terrain'|string> $mapTypeIds */ public function __construct( - public array $mapTypeIds = [], - public ControlPosition $position = ControlPosition::BLOCK_START_INLINE_START, - public MapTypeControlStyle $style = MapTypeControlStyle::DEFAULT, + private array $mapTypeIds = [], + private ControlPosition $position = ControlPosition::BLOCK_START_INLINE_START, + private MapTypeControlStyle $style = MapTypeControlStyle::DEFAULT, ) { } public function toArray(): array { return [ - 'mapTypeIds' => array_map( - fn (MapTypeId|string $mapTypeId) => $mapTypeId instanceof MapTypeId ? $mapTypeId->value : $mapTypeId, - $this->mapTypeIds - ), + 'mapTypeIds' => $this->mapTypeIds, 'position' => $this->position->value, 'style' => $this->style->value, ]; diff --git a/src/Map/src/Provider/GoogleMaps/Option/MapTypeControlStyle.php b/src/Map/src/Bridge/Google/src/Option/MapTypeControlStyle.php similarity index 94% rename from src/Map/src/Provider/GoogleMaps/Option/MapTypeControlStyle.php rename to src/Map/src/Bridge/Google/src/Option/MapTypeControlStyle.php index f9f4fc6b067..5242ce4e5ef 100644 --- a/src/Map/src/Provider/GoogleMaps/Option/MapTypeControlStyle.php +++ b/src/Map/src/Bridge/Google/src/Option/MapTypeControlStyle.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\GoogleMaps\Option; +namespace Symfony\UX\Map\Bridge\Google\Option; /** * Identifiers for common MapTypesControls. diff --git a/src/Map/src/Provider/GoogleMaps/Option/StreetViewControlOptions.php b/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php similarity index 78% rename from src/Map/src/Provider/GoogleMaps/Option/StreetViewControlOptions.php rename to src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php index 7583744846c..926b8831945 100644 --- a/src/Map/src/Provider/GoogleMaps/Option/StreetViewControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\GoogleMaps\Option; +namespace Symfony\UX\Map\Bridge\Google\Option; /** * Options for the rendering of the Street View pegman control on the map. @@ -18,10 +18,10 @@ * * @author Hugo Alliaume */ -final class StreetViewControlOptions +final readonly class StreetViewControlOptions { public function __construct( - public ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, + private ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, ) { } diff --git a/src/Map/src/Provider/GoogleMaps/Option/ZoomControlOptions.php b/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php similarity index 78% rename from src/Map/src/Provider/GoogleMaps/Option/ZoomControlOptions.php rename to src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php index ffa2f4b88ec..979947a2354 100644 --- a/src/Map/src/Provider/GoogleMaps/Option/ZoomControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\GoogleMaps\Option; +namespace Symfony\UX\Map\Bridge\Google\Option; /** * Options for the rendering of the zoom control. @@ -18,10 +18,10 @@ * * @author Hugo Alliaume */ -final class ZoomControlOptions +final readonly class ZoomControlOptions { public function __construct( - public ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, + private ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, ) { } diff --git a/src/Map/src/Provider/GoogleMaps/GoogleMapsProvider.php b/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php similarity index 73% rename from src/Map/src/Provider/GoogleMaps/GoogleMapsProvider.php rename to src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php index a01c696932d..fb214676dba 100644 --- a/src/Map/src/Provider/GoogleMaps/GoogleMapsProvider.php +++ b/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php @@ -9,28 +9,25 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\GoogleMaps; +namespace Symfony\UX\Map\Bridge\Google\Renderer; +use Symfony\UX\Map\Bridge\Google\GoogleOptions; use Symfony\UX\Map\MapOptionsInterface; -use Symfony\UX\Map\Provider\ProviderInterface; -use Symfony\UX\Map\Provider\ProviderTrait; +use Symfony\UX\Map\Renderer\AbstractRenderer; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; /** * @author Hugo Alliaume + * + * @internal */ -final readonly class GoogleMapsProvider implements ProviderInterface +final readonly class GoogleRenderer extends AbstractRenderer { - use ProviderTrait; - - public static function getDefaultMapOptions(): MapOptionsInterface - { - return new GoogleMapsOptions(); - } - /** * 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, @@ -41,14 +38,15 @@ public function __construct( private ?string $url = null, private ?string $version = null, ) { + parent::__construct($stimulusHelper); } - public function getName(): string + protected function getName(): string { - return 'google-maps'; + return 'google'; } - public function getOptions(): array + protected function getProviderOptions(): array { return array_filter([ 'id' => $this->id, @@ -61,10 +59,15 @@ public function getOptions(): array ]) + ['apiKey' => $this->apiKey]; } + protected function getDefaultMapOptions(): MapOptionsInterface + { + return new GoogleOptions(); + } + public function __toString(): string { return \sprintf( - 'google-maps://%s@default/?%s', + 'google://%s@default/?%s', str_repeat('*', \strlen($this->apiKey)), http_build_query(array_filter([ 'id' => $this->id, diff --git a/src/Map/src/Provider/GoogleMaps/GoogleMapsProviderFactory.php b/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php similarity index 65% rename from src/Map/src/Provider/GoogleMaps/GoogleMapsProviderFactory.php rename to src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php index ec00c824db6..04a39925050 100644 --- a/src/Map/src/Provider/GoogleMaps/GoogleMapsProviderFactory.php +++ b/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php @@ -9,29 +9,30 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\GoogleMaps; +namespace Symfony\UX\Map\Bridge\Google\Renderer; use Symfony\UX\Map\Exception\InvalidArgumentException; use Symfony\UX\Map\Exception\UnsupportedSchemeException; -use Symfony\UX\Map\Provider\AbstractProviderFactory; -use Symfony\UX\Map\Provider\Dsn; -use Symfony\UX\Map\Provider\ProviderFactoryInterface; -use Symfony\UX\Map\Provider\ProviderInterface; +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 GoogleMapsProviderFactory extends AbstractProviderFactory implements ProviderFactoryInterface +final class GoogleRendererFactory extends AbstractRendererFactory implements RendererFactoryInterface { - public function create(Dsn $dsn): ProviderInterface + public function create(Dsn $dsn): RendererInterface { if (!$this->supports($dsn)) { throw new UnsupportedSchemeException($dsn); } - $apiKey = $dsn->getUser() ?: throw new InvalidArgumentException('The Google Maps provider requires an API key as the user part of the DSN.'); + $apiKey = $dsn->getUser() ?: throw new InvalidArgumentException('The Google Maps renderer requires an API key as the user part of the DSN.'); - return new GoogleMapsProvider( + return new GoogleRenderer( + $this->stimulus, $apiKey, id: $dsn->getOption('id'), language: $dsn->getOption('language'), @@ -45,6 +46,6 @@ public function create(Dsn $dsn): ProviderInterface protected function getSupportedSchemes(): array { - return ['google-maps']; + 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..97a12acb93e --- /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\LatLng; +use Symfony\UX\Map\Map; +use Symfony\UX\Map\Marker; +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 LatLng(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 LatLng(48.8566, 2.3522), 'Paris')) + ->addMarker(new Marker(new LatLng(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/tests/Provider/GoogleMaps/Option/ControlPositionTest.php b/src/Map/src/Bridge/Google/tests/Option/ControlPositionTest.php similarity index 59% rename from src/Map/tests/Provider/GoogleMaps/Option/ControlPositionTest.php rename to src/Map/src/Bridge/Google/tests/Option/ControlPositionTest.php index ec4b7174055..71ef62e1167 100644 --- a/src/Map/tests/Provider/GoogleMaps/Option/ControlPositionTest.php +++ b/src/Map/src/Bridge/Google/tests/Option/ControlPositionTest.php @@ -9,10 +9,10 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Tests\Provider\GoogleMaps\Option; +namespace Symfony\UX\Map\Bridge\Google\Tests\Option; use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Provider\GoogleMaps\Option\ControlPosition; +use Symfony\UX\Map\Bridge\Google\Option\ControlPosition; class ControlPositionTest extends TestCase { @@ -24,23 +24,11 @@ public function testEnumValues(): void 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(11, ControlPosition::BOTTOM_CENTER->value); - self::assertSame(10, ControlPosition::BOTTOM_LEFT->value); - self::assertSame(12, ControlPosition::BOTTOM_RIGHT->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); - self::assertSame(6, ControlPosition::LEFT_BOTTOM->value); - self::assertSame(4, ControlPosition::LEFT_CENTER->value); - self::assertSame(5, ControlPosition::LEFT_TOP->value); - self::assertSame(9, ControlPosition::RIGHT_BOTTOM->value); - self::assertSame(8, ControlPosition::RIGHT_CENTER->value); - self::assertSame(7, ControlPosition::RIGHT_TOP->value); - self::assertSame(2, ControlPosition::TOP_CENTER->value); - self::assertSame(1, ControlPosition::TOP_LEFT->value); - self::assertSame(3, ControlPosition::TOP_RIGHT->value); } } diff --git a/src/Map/tests/Provider/GoogleMaps/Option/FullscreenControlOptionsTest.php b/src/Map/src/Bridge/Google/tests/Option/FullscreenControlOptionsTest.php similarity index 76% rename from src/Map/tests/Provider/GoogleMaps/Option/FullscreenControlOptionsTest.php rename to src/Map/src/Bridge/Google/tests/Option/FullscreenControlOptionsTest.php index 3f4b20deb5e..3de7164df68 100644 --- a/src/Map/tests/Provider/GoogleMaps/Option/FullscreenControlOptionsTest.php +++ b/src/Map/src/Bridge/Google/tests/Option/FullscreenControlOptionsTest.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Tests\Provider\GoogleMaps\Option; +namespace Symfony\UX\Map\Bridge\Google\Tests\Option; use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Provider\GoogleMaps\Option\ControlPosition; -use Symfony\UX\Map\Provider\GoogleMaps\Option\FullscreenControlOptions; +use Symfony\UX\Map\Bridge\Google\Option\ControlPosition; +use Symfony\UX\Map\Bridge\Google\Option\FullscreenControlOptions; class FullscreenControlOptionsTest extends TestCase { 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/tests/Provider/GoogleMaps/Option/MapTypeControlOptionsTest.php b/src/Map/src/Bridge/Google/tests/Option/MapTypeControlOptionsTest.php similarity index 68% rename from src/Map/tests/Provider/GoogleMaps/Option/MapTypeControlOptionsTest.php rename to src/Map/src/Bridge/Google/tests/Option/MapTypeControlOptionsTest.php index 69a9e1e27cd..fbc5ea19e81 100644 --- a/src/Map/tests/Provider/GoogleMaps/Option/MapTypeControlOptionsTest.php +++ b/src/Map/src/Bridge/Google/tests/Option/MapTypeControlOptionsTest.php @@ -9,20 +9,19 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Tests\Provider\GoogleMaps\Option; +namespace Symfony\UX\Map\Bridge\Google\Tests\Option; use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Provider\GoogleMaps\Option\ControlPosition; -use Symfony\UX\Map\Provider\GoogleMaps\Option\MapTypeControlOptions; -use Symfony\UX\Map\Provider\GoogleMaps\Option\MapTypeControlStyle; -use Symfony\UX\Map\Provider\GoogleMaps\Option\MapTypeId; +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: [MapTypeId::SATELLITE, 'hybrid'], + mapTypeIds: ['satellite', 'hybrid'], position: ControlPosition::BLOCK_END_INLINE_END, style: MapTypeControlStyle::HORIZONTAL_BAR, ); diff --git a/src/Map/tests/Provider/GoogleMaps/Option/MapTypeControlStyleTest.php b/src/Map/src/Bridge/Google/tests/Option/MapTypeControlStyleTest.php similarity index 82% rename from src/Map/tests/Provider/GoogleMaps/Option/MapTypeControlStyleTest.php rename to src/Map/src/Bridge/Google/tests/Option/MapTypeControlStyleTest.php index 0bf56be6fae..43818e932ca 100644 --- a/src/Map/tests/Provider/GoogleMaps/Option/MapTypeControlStyleTest.php +++ b/src/Map/src/Bridge/Google/tests/Option/MapTypeControlStyleTest.php @@ -9,10 +9,10 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Tests\Provider\GoogleMaps\Option; +namespace Symfony\UX\Map\Bridge\Google\Tests\Option; use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Provider\GoogleMaps\Option\MapTypeControlStyle; +use Symfony\UX\Map\Bridge\Google\Option\MapTypeControlStyle; class MapTypeControlStyleTest extends TestCase { diff --git a/src/Map/tests/Provider/GoogleMaps/Option/StreetViewControlOptionsTest.php b/src/Map/src/Bridge/Google/tests/Option/StreetViewControlOptionsTest.php similarity index 76% rename from src/Map/tests/Provider/GoogleMaps/Option/StreetViewControlOptionsTest.php rename to src/Map/src/Bridge/Google/tests/Option/StreetViewControlOptionsTest.php index 00f26479a91..5cf0742b006 100644 --- a/src/Map/tests/Provider/GoogleMaps/Option/StreetViewControlOptionsTest.php +++ b/src/Map/src/Bridge/Google/tests/Option/StreetViewControlOptionsTest.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Tests\Provider\GoogleMaps\Option; +namespace Symfony\UX\Map\Bridge\Google\Tests\Option; use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Provider\GoogleMaps\Option\ControlPosition; -use Symfony\UX\Map\Provider\GoogleMaps\Option\StreetViewControlOptions; +use Symfony\UX\Map\Bridge\Google\Option\ControlPosition; +use Symfony\UX\Map\Bridge\Google\Option\StreetViewControlOptions; class StreetViewControlOptionsTest extends TestCase { diff --git a/src/Map/tests/Provider/GoogleMaps/Option/ZoomControlOptionsTest.php b/src/Map/src/Bridge/Google/tests/Option/ZoomControlOptionsTest.php similarity index 61% rename from src/Map/tests/Provider/GoogleMaps/Option/ZoomControlOptionsTest.php rename to src/Map/src/Bridge/Google/tests/Option/ZoomControlOptionsTest.php index b2e74a238a8..929c960d432 100644 --- a/src/Map/tests/Provider/GoogleMaps/Option/ZoomControlOptionsTest.php +++ b/src/Map/src/Bridge/Google/tests/Option/ZoomControlOptionsTest.php @@ -9,22 +9,22 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Tests\Provider\GoogleMaps\Option; +namespace Symfony\UX\Map\Bridge\Google\Tests\Option; use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Provider\GoogleMaps\Option\ControlPosition; -use Symfony\UX\Map\Provider\GoogleMaps\Option\ZoomControlOptions; +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::BOTTOM_CENTER, + position: ControlPosition::BLOCK_START_INLINE_END, ); self::assertSame([ - 'position' => ControlPosition::BOTTOM_CENTER->value, + '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..0154e2c0879 --- /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\LatLng; +use Symfony\UX\Map\Map; + +$map = (new Map()) + ->center(new LatLng(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..c3614be9ec5 --- /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 { LatLng, MarkerDefinition } from '@symfony/ux-map/abstract-map-controller'; +import 'leaflet/dist/leaflet.min.css'; +import { Map, Marker, 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: LatLng; + zoom: number; + options: MapOptions; + }): Map; + 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..36481d028f5 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js @@ -0,0 +1,55 @@ +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(); + } + return marker.getPopup(); + } + 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..2d2ad12975c --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts @@ -0,0 +1,99 @@ +/* + * 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. + */ + +'use strict'; + +import AbstractMapController from '@symfony/ux-map/abstract-map-controller'; +import type { LatLng, 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, + Map, + Marker, + 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 { + 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: LatLng; zoom: number; options: MapOptions }): Map { + 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(); + } + + return marker.getPopup()!; + } + + 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..4130dc32b5e --- /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; + + 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/Provider/Leaflet/LeafletOptions.php b/src/Map/src/Bridge/Leaflet/src/LeafletOptions.php similarity index 67% rename from src/Map/src/Provider/Leaflet/LeafletOptions.php rename to src/Map/src/Bridge/Leaflet/src/LeafletOptions.php index cf51420a00b..0450477339d 100644 --- a/src/Map/src/Provider/Leaflet/LeafletOptions.php +++ b/src/Map/src/Bridge/Leaflet/src/LeafletOptions.php @@ -9,24 +9,31 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\Leaflet; +namespace Symfony\UX\Map\Bridge\Leaflet; +use Symfony\UX\Map\Bridge\Leaflet\Option\TileLayer; use Symfony\UX\Map\MapOptionsInterface; -use Symfony\UX\Map\Provider\Leaflet\Option\TileLayer; /** * @author Hugo Alliaume */ -final readonly class LeafletOptions implements MapOptionsInterface +final class LeafletOptions implements MapOptionsInterface { public function __construct( - private ?TileLayer $tileLayer = new TileLayer( + 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 [ diff --git a/src/Map/src/Provider/Leaflet/Option/TileLayer.php b/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php similarity index 68% rename from src/Map/src/Provider/Leaflet/Option/TileLayer.php rename to src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php index 9930a7786e9..526572538b3 100644 --- a/src/Map/src/Provider/Leaflet/Option/TileLayer.php +++ b/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\Leaflet\Option; +namespace Symfony\UX\Map\Bridge\Leaflet\Option; /** * Represents a tile layer for a Leaflet map. @@ -18,12 +18,15 @@ * * @author Hugo Alliaume */ -final class TileLayer +final readonly class TileLayer { + /** + * @param array $options + */ public function __construct( - public string $url, - public string $attribution, - public array $options = [], + private string $url, + private string $attribution, + private array $options = [], ) { } @@ -32,7 +35,7 @@ public function toArray(): array return [ 'url' => $this->url, 'attribution' => $this->attribution, - 'options' => $this->options, + 'options' => (object) $this->options, ]; } } diff --git a/src/Map/src/Provider/Leaflet/LeafletProvider.php b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php similarity index 54% rename from src/Map/src/Provider/Leaflet/LeafletProvider.php rename to src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php index 3e69656c69e..05f1348ef72 100644 --- a/src/Map/src/Provider/Leaflet/LeafletProvider.php +++ b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php @@ -9,35 +9,35 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\Leaflet; +namespace Symfony\UX\Map\Bridge\Leaflet\Renderer; +use Symfony\UX\Map\Bridge\Leaflet\LeafletOptions; use Symfony\UX\Map\MapOptionsInterface; -use Symfony\UX\Map\Provider\ProviderInterface; -use Symfony\UX\Map\Provider\ProviderTrait; +use Symfony\UX\Map\Renderer\AbstractRenderer; /** * @author Hugo Alliaume + * + * @internal */ -final readonly class LeafletProvider implements ProviderInterface +final readonly class LeafletRenderer extends AbstractRenderer { - use ProviderTrait; - - public static function getDefaultMapOptions(): MapOptionsInterface + protected function getName(): string { - return new LeafletOptions(); + return 'leaflet'; } - public function getName(): string + protected function getProviderOptions(): array { - return 'leaflet'; + return []; } - public function getOptions(): array + protected function getDefaultMapOptions(): MapOptionsInterface { - return []; + return new LeafletOptions(); } - public function __toString() + public function __toString(): string { return 'leaflet://default'; } diff --git a/src/Map/src/Provider/Leaflet/LeafletProviderFactory.php b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php similarity index 55% rename from src/Map/src/Provider/Leaflet/LeafletProviderFactory.php rename to src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php index b4cdfd83ac4..d5dc0c5dbd3 100644 --- a/src/Map/src/Provider/Leaflet/LeafletProviderFactory.php +++ b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php @@ -9,26 +9,26 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider\Leaflet; +namespace Symfony\UX\Map\Bridge\Leaflet\Renderer; use Symfony\UX\Map\Exception\UnsupportedSchemeException; -use Symfony\UX\Map\Provider\AbstractProviderFactory; -use Symfony\UX\Map\Provider\Dsn; -use Symfony\UX\Map\Provider\ProviderFactoryInterface; -use Symfony\UX\Map\Provider\ProviderInterface; +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 LeafletProviderFactory extends AbstractProviderFactory implements ProviderFactoryInterface +final class LeafletRendererFactory extends AbstractRendererFactory implements RendererFactoryInterface { - public function create(Dsn $dsn): ProviderInterface + public function create(Dsn $dsn): RendererInterface { if (!$this->supports($dsn)) { throw new UnsupportedSchemeException($dsn); } - return new LeafletProvider(); + return new LeafletRenderer($this->stimulus); } protected function getSupportedSchemes(): array diff --git a/src/Map/tests/Provider/Leaflet/LeafletOptionsTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php similarity index 65% rename from src/Map/tests/Provider/Leaflet/LeafletOptionsTest.php rename to src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php index 586203723d1..a5d50fba7a7 100644 --- a/src/Map/tests/Provider/Leaflet/LeafletOptionsTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Tests\Provider\Leaflet; +namespace Symfony\UX\Map\Bridge\Leaflet\Tests; use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Provider\Leaflet\LeafletOptions; -use Symfony\UX\Map\Provider\Leaflet\Option as LeafletOption; +use Symfony\UX\Map\Bridge\Leaflet\LeafletOptions; +use Symfony\UX\Map\Bridge\Leaflet\Option\TileLayer; class LeafletOptionsTest extends TestCase { @@ -21,19 +21,21 @@ 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' => [], + 'options' => $array['tileLayer']['options'], // stdClass ], - ], $leafletOptions->toArray()); + ], $array); } public function testWithMaximumConfiguration(): void { $leafletOptions = new LeafletOptions( - tileLayer: new LeafletOption\TileLayer( + tileLayer: new TileLayer( url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap', options: [ @@ -45,17 +47,18 @@ public function testWithMaximumConfiguration(): void ), ); + $array = $leafletOptions->toArray(); + self::assertSame([ 'tileLayer' => [ 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'attribution' => '© OpenStreetMap', - 'options' => [ - 'maxZoom' => 19, - 'minZoom' => 1, - 'maxNativeZoom' => 18, - 'zoomOffset' => 0, - ], + 'options' => $array['tileLayer']['options'], // stdClass ], - ], $leafletOptions->toArray()); + ], $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..c59965d11a0 --- /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\LatLng; +use Symfony\UX\Map\Map; +use Symfony\UX\Map\Marker; +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 LatLng(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 LatLng(48.8566, 2.3522), 'Paris')) + ->addMarker(new Marker(new LatLng(48.8566, 2.3522), 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'))), + ]; + } +} diff --git a/src/Map/tests/Provider/Leaflet/Option/TileLayerTest.php b/src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.php similarity index 69% rename from src/Map/tests/Provider/Leaflet/Option/TileLayerTest.php rename to src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.php index a0e1c262581..c0c8f998d78 100644 --- a/src/Map/tests/Provider/Leaflet/Option/TileLayerTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.php @@ -9,16 +9,16 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Tests\Provider\Leaflet\Option; +namespace Symfony\UX\Map\Bridge\Leaflet\Tests\Option; use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Provider\Leaflet\Option as LeafletOption; +use Symfony\UX\Map\Bridge\Leaflet\Option\TileLayer; class TileLayerTest extends TestCase { public function testToArray() { - $tileLayer = new LeafletOption\TileLayer( + $tileLayer = new TileLayer( url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors', options: [ @@ -26,12 +26,13 @@ public function testToArray() ], ); + $array = $tileLayer->toArray(); + self::assertSame([ 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'attribution' => '© OpenStreetMap contributors', - 'options' => [ - 'maxZoom' => 19, - ], - ], $tileLayer->toArray()); + 'options' => $array['options'], // stdClass + ], $array); + self::assertSame(19, $array['options']->maxZoom); } } diff --git a/src/Map/src/DependencyInjection/UXMapExtension.php b/src/Map/src/DependencyInjection/UXMapExtension.php deleted file mode 100644 index 42502f87e02..00000000000 --- a/src/Map/src/DependencyInjection/UXMapExtension.php +++ /dev/null @@ -1,85 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\DependencyInjection; - -use Symfony\Component\AssetMapper\AssetMapperInterface; -use Symfony\Component\Config\Definition\Builder\TreeBuilder; -use Symfony\Component\Config\Definition\ConfigurationInterface; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; -use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; - -/** - * @author Hugo Alliaume - */ -class UXMapExtension extends Extension implements ConfigurationInterface, PrependExtensionInterface -{ - public function load(array $configs, ContainerBuilder $container): void - { - $config = $this->processConfiguration($this, $configs); - - $loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../config'))); - $loader->load('services.php'); - - if ($this->isAssetMapperAvailable($container)) { - $loader->load('asset_mapper.php'); - } - - $container->getDefinition('ux_map.default_provider') - ->setArgument(0, $config['provider']); - } - - public function getConfigTreeBuilder(): TreeBuilder - { - $treeBuilder = new TreeBuilder('ux_map'); - $rootNode = $treeBuilder->getRootNode(); - $rootNode - ->children() - ->scalarNode('provider')->isRequired()->end() - ->end() - ; - - return $treeBuilder; - } - - public function prepend(ContainerBuilder $container): void - { - if (!$this->isAssetMapperAvailable($container)) { - return; - } - - $container->prependExtensionConfig('framework', [ - 'asset_mapper' => [ - 'paths' => [ - __DIR__.'/../../assets/dist' => '@symfony/ux-map', - ], - ], - ]); - } - - private function isAssetMapperAvailable(ContainerBuilder $container): bool - { - if (!interface_exists(AssetMapperInterface::class)) { - return false; - } - - // check that FrameworkBundle 6.3 or higher is installed - $bundlesMetadata = $container->getParameter('kernel.bundles_metadata'); - if (!isset($bundlesMetadata['FrameworkBundle'])) { - return false; - } - - return is_file($bundlesMetadata['FrameworkBundle']['path'].'/Resources/config/asset_mapper.php'); - } -} diff --git a/src/Map/src/Provider/ProviderTrait.php b/src/Map/src/Exception/LogicException.php similarity index 62% rename from src/Map/src/Provider/ProviderTrait.php rename to src/Map/src/Exception/LogicException.php index 28141d54d17..6cf0251371c 100644 --- a/src/Map/src/Provider/ProviderTrait.php +++ b/src/Map/src/Exception/LogicException.php @@ -9,15 +9,11 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider; +namespace Symfony\UX\Map\Exception; /** * @author Hugo Alliaume */ -trait ProviderTrait +class LogicException extends \LogicException implements Exception { - public function getStimulusDataController(): string - { - return '@symfony/ux-map/'.$this->getName(); - } } diff --git a/src/Map/src/Exception/UnsupportedSchemeException.php b/src/Map/src/Exception/UnsupportedSchemeException.php index 5df69483a2b..cfec4fda20a 100644 --- a/src/Map/src/Exception/UnsupportedSchemeException.php +++ b/src/Map/src/Exception/UnsupportedSchemeException.php @@ -11,7 +11,8 @@ namespace Symfony\UX\Map\Exception; -use Symfony\UX\Map\Provider\Dsn; +use Symfony\UX\Map\Renderer\Dsn; +use Symfony\UX\Map\UXMapBundle; /** * @author Hugo Alliaume @@ -20,8 +21,16 @@ 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 provider "%s" is not supported.', $dsn->getScheme()), + \sprintf('The renderer "%s" is not supported.', $dsn->getScheme()), 0, $previous ); diff --git a/src/Map/src/Map.php b/src/Map/src/Map.php index 9063900352d..7fa06f31686 100644 --- a/src/Map/src/Map.php +++ b/src/Map/src/Map.php @@ -12,7 +12,6 @@ namespace Symfony\UX\Map; use Symfony\UX\Map\Exception\InvalidArgumentException; -use Symfony\UX\Map\Provider\ProviderInterface; /** * Represents a map. @@ -21,14 +20,9 @@ */ final class Map { - /** - * @var array - */ - private $attributes = []; - public function __construct( - private readonly ProviderInterface $provider, - private MapOptionsInterface $options, + private readonly ?string $rendererName = null, + private ?MapOptionsInterface $options = null, private ?LatLng $center = null, private ?float $zoom = null, private bool $fitBoundsToMarkers = false, @@ -36,13 +30,14 @@ public function __construct( * @var array */ private array $markers = [], - /** - * @var array - */ - private array $infoWindows = [], ) { } + public function getRendererName(): ?string + { + return $this->rendererName; + } + public function center(LatLng $center): self { $this->center = $center; @@ -64,11 +59,6 @@ public function fitBoundsToMarkers(bool $enable = true): self return $this; } - public function getProvider(): ProviderInterface - { - return $this->provider; - } - public function options(MapOptionsInterface $options): self { $this->options = $options; @@ -76,42 +66,23 @@ public function options(MapOptionsInterface $options): self return $this; } - public function addMarker(Marker $marker): self + public function getOptions(): ?MapOptionsInterface { - $this->markers[] = $marker; - - return $this; + return $this->options; } - public function addInfoWindow(InfoWindow $infoWindow): self + public function hasOptions(): bool { - $this->infoWindows[] = $infoWindow; - - return $this; + return null !== $this->options; } - public function setAttributes(array $attributes): self + public function addMarker(Marker $marker): self { - $this->attributes = $attributes; + $this->markers[] = $marker; return $this; } - public function getMainDataController(): string - { - return $this->provider->getStimulusDataController(); - } - - public function getDataController(): ?string - { - return $this->attributes['data-controller'] ?? null; - } - - public function getAttributes(): array - { - return $this->attributes; - } - public function toArray(): array { if (null === $this->center) { @@ -126,9 +97,8 @@ public function toArray(): array 'center' => $this->center->toArray(), 'zoom' => $this->zoom, 'fitBoundsToMarkers' => $this->fitBoundsToMarkers, - 'options' => $this->options->toArray(), + 'options' => (object) ($this->options?->toArray() ?? []), 'markers' => array_map(static fn (Marker $marker) => $marker->toArray(), $this->markers), - 'infoWindows' => array_map(static fn (InfoWindow $infoWindow) => $infoWindow->toArray(), $this->infoWindows), ]; } } diff --git a/src/Map/src/MapFactory.php b/src/Map/src/MapFactory.php deleted file mode 100644 index 039bdc3251c..00000000000 --- a/src/Map/src/MapFactory.php +++ /dev/null @@ -1,40 +0,0 @@ - - * - * 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\Provider\ProviderInterface; - -/** - * Creates a map based on the configuration, and registers it in the map registry. - * - * @internal - * - * @author Hugo Alliaume - */ -final readonly class MapFactory -{ - public function __construct( - private ProviderInterface $defaultProvider, - private MapRegistry $mapRegistry, - ) { - } - - public function createMap(?ProviderInterface $provider = null): Map - { - $provider ??= $this->defaultProvider; - $map = new Map($provider, $provider::getDefaultMapOptions()); - - $this->mapRegistry->register($map); - - return $map; - } -} diff --git a/src/Map/src/MapRegistry.php b/src/Map/src/MapRegistry.php deleted file mode 100644 index d7fad4d4f89..00000000000 --- a/src/Map/src/MapRegistry.php +++ /dev/null @@ -1,40 +0,0 @@ - - * - * 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\Contracts\Service\ResetInterface; - -/** - * @author Hugo Alliaume - */ -final class MapRegistry implements ResetInterface -{ - /** - * @var array - */ - private array $maps = []; - - public function register(Map $map): void - { - $this->maps[] = $map; - } - - public function all(): array - { - return $this->maps; - } - - public function reset(): void - { - $this->maps = []; - } -} diff --git a/src/Map/src/Provider/GoogleMaps/Option/ControlPosition.php b/src/Map/src/Provider/GoogleMaps/Option/ControlPosition.php deleted file mode 100644 index 5b536eb8eaf..00000000000 --- a/src/Map/src/Provider/GoogleMaps/Option/ControlPosition.php +++ /dev/null @@ -1,160 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Provider\GoogleMaps\Option; - -/** - * Identifiers used to specify the placement of controls on the map. - * Controls are positioned relative to other controls in the same layout position. - * Controls that are added first are positioned closer to the edge of the map. - * Usage of "logical values" (see https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values) is recommended - * in order to be able to automatically support both left-to-right (LTR) and right-to-left (RTL) layout contexts. - * - * @see https://developers.google.com/maps/documentation/javascript/reference/control#ControlPosition - * - * @author Hugo Alliaume - */ -enum ControlPosition: int -{ - /** - * Equivalent to {@see self::BOTTOM_CENTER} in both LTR and RTL. - */ - case BLOCK_END_INLINE_CENTER = 24; - - /** - * Equivalent to {@see self::BOTTOM_RIGHT} in LTR, or {@see self::BOTTOM_LEFT} in RTL. - */ - case BLOCK_END_INLINE_END = 25; - - /** - * Equivalent to {@see self::BOTTOM_LEFT} in LTR, or {@see self::BOTTOM_RIGHT} in RTL. - */ - case BLOCK_END_INLINE_START = 23; - - /** - * Equivalent to {@see self::TOP_CENTER} in both LTR and RTL. - */ - case BLOCK_START_INLINE_CENTER = 15; - - /** - * Equivalent to {@see self::TOP_RIGHT} in LTR, or {@see self::TOP_LEFT} in RTL. - */ - case BLOCK_START_INLINE_END = 16; - - /** - * Equivalent to {@see self::TOP_LEFT} in LTR, or {@see self::TOP_RIGHT} in RTL. - */ - case BLOCK_START_INLINE_START = 14; - - /** - * Elements are positioned in the center of the bottom row. Consider using - * {@see self::BLOCK_END_INLINE_CENTER} instead. - */ - case BOTTOM_CENTER = 11; - - /** - * Elements are positioned in the bottom left and flow towards the middle. - * Elements are positioned to the right of the Google logo. Consider using - * {@see self::BLOCK_END_INLINE_START} instead. - */ - case BOTTOM_LEFT = 10; - - /** - * Elements are positioned in the bottom right and flow towards the middle. - * Elements are positioned to the left of the copyrights. Consider using - * {@see self::BLOCK_END_INLINE_END} instead. - */ - case BOTTOM_RIGHT = 12; - - /** - * Equivalent to {@see self::RIGHT_CENTER} in LTR, or {@see self::LEFT_CENTER} in RTL. - */ - case INLINE_END_BLOCK_CENTER = 21; - - /** - * Equivalent to {@see self::RIGHT_BOTTOM} in LTR, or {@see self::LEFT_BOTTOM} in RTL. - */ - case INLINE_END_BLOCK_END = 22; - - /** - * Equivalent to {@see self::RIGHT_TOP} in LTR, or {@see self::LEFT_TOP} in RTL. - */ - case INLINE_END_BLOCK_START = 20; - - /** - * Equivalent to {@see self::LEFT_CENTER} in LTR, or {@see self::RIGHT_CENTER} in RTL. - */ - case INLINE_START_BLOCK_CENTER = 17; - - /** - * Equivalent to {@see self::LEFT_BOTTOM} in LTR, or {@see self::RIGHT_BOTTOM} in RTL. - */ - case INLINE_START_BLOCK_END = 19; - - /** - * Equivalent to {@see self::LEFT_TOP} in LTR, or {@see self::RIGHT_TOP} in RTL. - */ - case INLINE_START_BLOCK_START = 18; - - /** - * Elements are positioned on the left, above bottom-left elements, and flow - * upwards. Consider using {@see self::INLINE_START_BLOCK_END} instead. - */ - case LEFT_BOTTOM = 6; - - /** - * Elements are positioned in the center of the left side. Consider using - * {@see self::INLINE_START_BLOCK_CENTER} instead. - */ - case LEFT_CENTER = 4; - - /** - * Elements are positioned on the left, below top-left elements, and flow - * downwards. Consider using {@see self::INLINE_START_BLOCK_START} instead. - */ - case LEFT_TOP = 5; - - /** - * Elements are positioned on the right, above bottom-right elements, and - * flow upwards. Consider using {@see self::INLINE_END_BLOCK_END} instead. - */ - case RIGHT_BOTTOM = 9; - - /** - * Elements are positioned in the center of the right side. Consider using - * {@see self::INLINE_END_BLOCK_CENTER} instead. - */ - case RIGHT_CENTER = 8; - - /** - * Elements are positioned on the right, below top-right elements, and flow - * downwards. Consider using {@see self::INLINE_END_BLOCK_START} instead. - */ - case RIGHT_TOP = 7; - - /** - * Elements are positioned in the center of the top row. Consider using - * {@see self::BLOCK_START_INLINE_CENTER} instead. - */ - case TOP_CENTER = 2; - - /** - * Elements are positioned in the top left and flow towards the middle. - * Consider using {@see self::BLOCK_START_INLINE_START} instead. - */ - case TOP_LEFT = 1; - - /** - * Elements are positioned in the top right and flow towards the middle. - * Consider using {@see self::BLOCK_START_INLINE_END} instead. - */ - case TOP_RIGHT = 3; -} diff --git a/src/Map/src/Provider/GoogleMaps/Option/MapTypeId.php b/src/Map/src/Provider/GoogleMaps/Option/MapTypeId.php deleted file mode 100644 index 25b8d01486c..00000000000 --- a/src/Map/src/Provider/GoogleMaps/Option/MapTypeId.php +++ /dev/null @@ -1,42 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Provider\GoogleMaps\Option; - -/** - * Identifiers for common MapTypes. - * - * @see https://developers.google.com/maps/documentation/javascript/reference/map#MapTypeId - * - * @author Hugo Alliaume - */ -enum MapTypeId: string -{ - /** - * This map type displays a transparent layer of major streets on satellite images. - */ - case HYBRID = 'hybrid'; - - /** - * This map type displays a normal street map. - */ - case ROADMAP = 'roadmap'; - - /** - * This map type displays satellite images. - */ - case SATELLITE = 'satellite'; - - /** - * This map type displays maps with physical features such as terrain and vegetation. - */ - case TERRAIN = 'terrain'; -} diff --git a/src/Map/src/Provider/ProviderInterface.php b/src/Map/src/Provider/ProviderInterface.php deleted file mode 100644 index 9849d0f6a17..00000000000 --- a/src/Map/src/Provider/ProviderInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Provider; - -use Symfony\UX\Map\MapOptionsInterface; - -/** - * @author Hugo Alliaume - */ -interface ProviderInterface extends \Stringable -{ - public static function getDefaultMapOptions(): MapOptionsInterface; - - public function getName(): string; - - public function getOptions(): array; - - public function getStimulusDataController(): string; -} 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/Provider/AbstractProviderFactory.php b/src/Map/src/Renderer/AbstractRendererFactory.php similarity index 77% rename from src/Map/src/Provider/AbstractProviderFactory.php rename to src/Map/src/Renderer/AbstractRendererFactory.php index 8bcf980ec72..02587a75d09 100644 --- a/src/Map/src/Provider/AbstractProviderFactory.php +++ b/src/Map/src/Renderer/AbstractRendererFactory.php @@ -9,15 +9,21 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider; +namespace Symfony\UX\Map\Renderer; use Symfony\UX\Map\Exception\IncompleteDsnException; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; /** * @author Hugo Alliaume */ -abstract class AbstractProviderFactory +abstract class AbstractRendererFactory { + public function __construct( + protected StimulusHelper $stimulus, + ) { + } + public function supports(Dsn $dsn): bool { return \in_array($dsn->getScheme(), $this->getSupportedSchemes(), true); diff --git a/src/Map/src/Provider/Dsn.php b/src/Map/src/Renderer/Dsn.php similarity index 88% rename from src/Map/src/Provider/Dsn.php rename to src/Map/src/Renderer/Dsn.php index 41e8a65815b..ecac16ddff0 100644 --- a/src/Map/src/Provider/Dsn.php +++ b/src/Map/src/Renderer/Dsn.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider; +namespace Symfony\UX\Map\Renderer; use Symfony\UX\Map\Exception\InvalidArgumentException; @@ -29,16 +29,16 @@ public function __construct(#[\SensitiveParameter] string $dsn) $this->originalDsn = $dsn; if (false === $params = parse_url($dsn)) { - throw new InvalidArgumentException('The map provider DSN is invalid.'); + throw new InvalidArgumentException('The map renderer DSN is invalid.'); } if (!isset($params['scheme'])) { - throw new InvalidArgumentException('The map provider DSN must contain a 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 provider DSN must contain a host (use "default" by default).'); + throw new InvalidArgumentException('The map renderer DSN must contain a host (use "default" by default).'); } $this->host = $params['host']; 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/Provider/Provider.php b/src/Map/src/Renderer/Renderer.php similarity index 55% rename from src/Map/src/Provider/Provider.php rename to src/Map/src/Renderer/Renderer.php index b8966bbbcb4..ca2da7fa071 100644 --- a/src/Map/src/Provider/Provider.php +++ b/src/Map/src/Renderer/Renderer.php @@ -9,35 +9,41 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider; +namespace Symfony\UX\Map\Renderer; use Symfony\UX\Map\Exception\UnsupportedSchemeException; /** * @author Hugo Alliaume + * + * @internal */ -final readonly class Provider +final readonly class Renderer { - /** - * @var array - */ - private array $factories; + public function __construct( + /** + * @param iterable $factories + */ + private iterable $factories, + ) { + } - public function __construct(iterable $factoriesIterator) + public function fromStrings(#[\SensitiveParameter] array $dsns): Renderers { - $factories = []; - foreach ($factoriesIterator as $name => $factory) { - $factories[$name] = $factory; + $renderers = []; + foreach ($dsns as $name => $dsn) { + $renderers[$name] = $this->fromString($dsn); } - $this->factories = $factories; + + return new Renderers($renderers); } - public function fromString(#[\SensitiveParameter] string $dsn): ProviderInterface + public function fromString(#[\SensitiveParameter] string $dsn): RendererInterface { return $this->fromDsnObject(new Dsn($dsn)); } - public function fromDsnObject(Dsn $dsn): ProviderInterface + public function fromDsnObject(Dsn $dsn): RendererInterface { foreach ($this->factories as $factory) { if ($factory->supports($dsn)) { diff --git a/src/Map/src/Provider/ProviderFactoryInterface.php b/src/Map/src/Renderer/RendererFactoryInterface.php similarity index 72% rename from src/Map/src/Provider/ProviderFactoryInterface.php rename to src/Map/src/Renderer/RendererFactoryInterface.php index 6f1a270f9df..254c1bf9a51 100644 --- a/src/Map/src/Provider/ProviderFactoryInterface.php +++ b/src/Map/src/Renderer/RendererFactoryInterface.php @@ -9,14 +9,14 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Provider; +namespace Symfony\UX\Map\Renderer; /** * @author Hugo Alliaume */ -interface ProviderFactoryInterface +interface RendererFactoryInterface { - public function create(Dsn $dsn): ProviderInterface; + 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/ProviderFactoryTestCase.php b/src/Map/src/Test/ProviderFactoryTestCase.php deleted file mode 100644 index 3b516e501d7..00000000000 --- a/src/Map/src/Test/ProviderFactoryTestCase.php +++ /dev/null @@ -1,111 +0,0 @@ - - * - * 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\IncompleteDsnException; -use Symfony\UX\Map\Exception\UnsupportedSchemeException; -use Symfony\UX\Map\Provider\Dsn; -use Symfony\UX\Map\Provider\ProviderFactoryInterface; -use Symfony\UX\Map\Provider\ProviderInterface; - -/** - * A test case to ease testing a provider factory. - * - * @author Oskar Stark - * @author Hugo Alliaume - */ -abstract class ProviderFactoryTestCase extends TestCase -{ - abstract public function createFactory(): ProviderFactoryInterface; - - /** - * @return iterable - */ - abstract public static function supportsProvider(): iterable; - - /** - * @return iterable - */ - abstract public static function createProvider(): iterable; - - /** - * @return iterable - */ - public static function unsupportedSchemeProvider(): iterable - { - return []; - } - - /** - * @return iterable - */ - public static function incompleteDsnProvider(): iterable - { - return []; - } - - /** - * @dataProvider supportsProvider - */ - public function testSupports(bool $expected, string $dsn) - { - $factory = $this->createFactory(); - - $this->assertSame($expected, $factory->supports(new Dsn($dsn))); - } - - /** - * @dataProvider createProvider - */ - public function testCreate(string $expected, string $dsn) - { - $factory = $this->createFactory(); - $provider = $factory->create(new Dsn($dsn)); - - $this->assertSame($expected, (string) $provider); - } - - /** - * @dataProvider unsupportedSchemeProvider - */ - public function testUnsupportedSchemeException(string $dsn, ?string $message = null) - { - $factory = $this->createFactory(); - - $dsn = new Dsn($dsn); - - $this->expectException(UnsupportedSchemeException::class); - if (null !== $message) { - $this->expectExceptionMessage($message); - } - - $factory->create($dsn); - } - - /** - * @dataProvider incompleteDsnProvider - */ - public function testIncompleteDsnException(string $dsn, ?string $message = null) - { - $factory = $this->createFactory(); - - $dsn = new Dsn($dsn); - - $this->expectException(IncompleteDsnException::class); - if (null !== $message) { - $this->expectExceptionMessage($message); - } - - $factory->create($dsn); - } -} diff --git a/src/Map/src/Test/ProviderTestCase.php b/src/Map/src/Test/ProviderTestCase.php deleted file mode 100644 index 992a2607cc5..00000000000 --- a/src/Map/src/Test/ProviderTestCase.php +++ /dev/null @@ -1,37 +0,0 @@ - - * - * 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\Provider\ProviderInterface; - -/** - * A test case to ease testing a provider. - */ -abstract class ProviderTestCase extends TestCase -{ - abstract public function testGetName(): void; - - /** - * @return iterable - */ - abstract public function provideTestOptions(); - - /** - * @dataProvider provideTestOptions - */ - public function testOptions(ProviderInterface $provider, string $expectedToString, array $expectedGetOptions): void - { - self::assertSame($expectedToString, (string) $provider); - self::assertSame($expectedGetOptions, $provider->getOptions()); - } -} 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 index fe992078826..b55e5de562d 100644 --- a/src/Map/src/Twig/MapExtension.php +++ b/src/Map/src/Twig/MapExtension.php @@ -11,17 +11,19 @@ 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('ux_map_script_tags', [MapRuntime::class, 'renderScriptTags'], ['is_safe' => ['html']]); - yield new TwigFunction('render_map', [MapRuntime::class, 'renderMap'], ['is_safe' => ['html']]); + yield new TwigFunction('render_map', [Renderers::class, 'renderMap'], ['is_safe' => ['html']]); } } diff --git a/src/Map/src/Twig/MapRuntime.php b/src/Map/src/Twig/MapRuntime.php deleted file mode 100644 index cff9c553ccd..00000000000 --- a/src/Map/src/Twig/MapRuntime.php +++ /dev/null @@ -1,76 +0,0 @@ - - * - * 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\Map; -use Symfony\UX\Map\MapRegistry; -use Symfony\UX\StimulusBundle\Helper\StimulusHelper; -use Twig\Extension\RuntimeExtensionInterface; - -/** - * @author Hugo Alliaume - */ -final class MapRuntime implements RuntimeExtensionInterface -{ - public function __construct( - private readonly StimulusHelper $stimulus, - private readonly MapRegistry $mapRegistry, - ) { - } - - public function renderScriptTags(): string - { - if (!$maps = $this->mapRegistry->all()) { - return ''; - } - - $scriptTags = []; - - $jsConfig = ['providers' => []]; - foreach ($maps as $map) { - $jsConfig['providers'][$map->getProvider()->getName()] = (object) $map->getProvider()->getOptions(); - } - $scriptTags[] = \sprintf('', json_encode($jsConfig, flags: \JSON_THROW_ON_ERROR)); - - return implode("\n", $scriptTags); - } - - public function renderMap(Map $map, array $attributes = []): string - { - $map->setAttributes($attributes + $map->getAttributes()); - - $controllers = []; - if ($map->getDataController()) { - $controllers[$map->getDataController()] = []; - } - $controllers[$map->getMainDataController()] = ['view' => $map->toArray()]; - - $stimulusAttributes = $this->stimulus->createStimulusAttributes(); - foreach ($controllers as $name => $controllerValues) { - $stimulusAttributes->addController($name, $controllerValues); - } - - foreach ($map->getAttributes() as $name => $value) { - if ('data-controller' === $name) { - continue; - } - - if (true === $value) { - $stimulusAttributes->addAttribute($name, $name); - } elseif (false !== $value) { - $stimulusAttributes->addAttribute($name, $value); - } - } - - return \sprintf('
', $stimulusAttributes); - } -} diff --git a/src/Map/src/UXMapBundle.php b/src/Map/src/UXMapBundle.php index 27014e62953..9f7f39e940c 100644 --- a/src/Map/src/UXMapBundle.php +++ b/src/Map/src/UXMapBundle.php @@ -11,15 +11,99 @@ namespace Symfony\UX\Map; -use Symfony\Component\HttpKernel\Bundle\Bundle; +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 Bundle +final class UXMapBundle extends AbstractBundle { - public function getPath(): string + 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 \dirname(__DIR__); + return interface_exists(AssetMapperInterface::class); } } diff --git a/src/Map/tests/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompilerTest.php b/src/Map/tests/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompilerTest.php deleted file mode 100644 index 00ba5c85d62..00000000000 --- a/src/Map/tests/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompilerTest.php +++ /dev/null @@ -1,86 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Tests\AssetMapper\ImportMap\Compiler; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\AssetMapper\AssetMapperInterface; -use Symfony\Component\AssetMapper\MappedAsset; -use Symfony\UX\Map\AssetMapper\ImportMap\Compiler\LeafletReplaceImagesAssetCompiler; - -class LeafletReplaceImagesAssetCompilerTest extends TestCase -{ - /** - * @dataProvider provideCompileTests - */ - public function testCompile(string $input, string $expectedInputContains, string $expectedOutputContains, array $expectedDependencies) - { - $assetMapper = $this->createMock(AssetMapperInterface::class); - $assetMapper->expects($this->any()) - ->method('getAssetFromSourcePath') - ->willReturnCallback(function ($path) { - return match ($path) { - '/project/assets/vendor/leaflet/dist/images/layers.png' => new MappedAsset( - logicalPath: 'vendor/leaflet/dist/images/layers.png', - publicPathWithoutDigest: '/assets/vendor/leaflet/dist/images/layers.png', - publicPath: '/assets/vendor/leaflet/dist/images/layers-abcd123.png', - ), - '/project/assets/vendor/leaflet/dist/images/layers-2x.png' => new MappedAsset( - logicalPath: 'vendor/leaflet/dist/images/layers-2x.png', - publicPathWithoutDigest: '/assets/vendor/leaflet/dist/images/layers-2x.png', - publicPath: '/assets/vendor/leaflet/dist/images/layers-2x-abcd123.png', - ), - '/project/assets/vendor/leaflet/dist/images/marker-icon.png' => new MappedAsset( - logicalPath: 'vendor/leaflet/dist/images/marker-icon.png', - publicPathWithoutDigest: '/assets/vendor/leaflet/dist/images/marker-icon.png', - publicPath: '/assets/vendor/leaflet/dist/images/marker-icon-abcd123.png', - ), - '/project/assets/vendor/leaflet/dist/images/marker-icon-2x.png' => new MappedAsset( - logicalPath: 'vendor/leaflet/dist/images/marker-icon-2x.png', - publicPathWithoutDigest: '/assets/vendor/leaflet/dist/images/marker-icon-2x.png', - publicPath: '/assets/vendor/leaflet/dist/images/marker-icon-2x-abcd123.png', - ), - '/project/assets/vendor/leaflet/dist/images/marker-shadow.png' => new MappedAsset( - logicalPath: 'vendor/leaflet/dist/images/marker-shadow.png', - publicPathWithoutDigest: '/assets/vendor/leaflet/dist/images/marker-shadow.png', - publicPath: '/assets/vendor/leaflet/dist/images/marker-shadow-abcd123.png', - ), - default => null, - }; - }); - - self::assertStringContainsString($expectedInputContains, $input, 'The input should contain the expected string'); - - $compiler = new LeafletReplaceImagesAssetCompiler(); - $asset = new MappedAsset('vendor/leaflet/leaflet.index.js', '/project/assets/vendor/leaflet/leaflet.index.js', '/assets/vendor/leaflet/leaflet.index.js'); - - $output = $compiler->compile($input, $asset, $assetMapper); - self::assertStringNotContainsString($expectedInputContains, $output, 'The output should not contain the expected string'); - self::assertStringContainsString($expectedOutputContains, $output, 'The output should contain the expected string'); - - $assetDependencyLogicalPaths = array_map(fn (MappedAsset $dependency) => $dependency->logicalPath, $asset->getDependencies()); - self::assertSame($expectedDependencies, $assetDependencyLogicalPaths); - } - - public static function provideCompileTests(): iterable - { - yield 'leaflet 1.9.4' => [ - 'input' => file_get_contents(__DIR__.'/../Fixtures/leaflet.1.9.4.js'), - 'expectedInputContains' => 'iconUrl:"marker-icon.png",iconRetinaUrl:"marker-icon-2x.png",shadowUrl:"marker-shadow.png"', - 'expectedOutputContains' => 'iconUrl:"/assets/vendor/leaflet/dist/images/marker-icon-abcd123.png",iconRetinaUrl:"/assets/vendor/leaflet/dist/images/marker-icon-2x-abcd123.png",shadowUrl:"/assets/vendor/leaflet/dist/images/marker-shadow-abcd123.png"', - 'expectedDependencies' => [ - 'vendor/leaflet/dist/images/marker-icon.png', - 'vendor/leaflet/dist/images/marker-icon-2x.png', - 'vendor/leaflet/dist/images/marker-shadow.png', - ], - ]; - } -} diff --git a/src/Map/tests/AssetMapper/ImportMap/Fixtures/leaflet.1.9.4.js b/src/Map/tests/AssetMapper/ImportMap/Fixtures/leaflet.1.9.4.js deleted file mode 100644 index f386e8c3ff6..00000000000 --- a/src/Map/tests/AssetMapper/ImportMap/Fixtures/leaflet.1.9.4.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js - */ -/* @preserve - * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com - * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1 - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Tests\AssetMapper\ImportMap\Resolver; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; -use Symfony\Component\AssetMapper\ImportMap\ImportMapType; -use Symfony\Component\AssetMapper\ImportMap\Resolver\PackageResolverInterface; -use Symfony\Component\HttpClient\MockHttpClient; -use Symfony\Component\HttpClient\Response\MockResponse; -use Symfony\UX\Map\AssetMapper\ImportMap\Resolver\LeafletPackageResolver; - -class LeafletPackageResolverTest extends TestCase -{ - public function testResolverShouldNotDoAnythingIfLeafletIsNotRequired(): void - { - $innerPackageResolver = $this->createMock(PackageResolverInterface::class); - $innerPackageResolver->expects(self::once()) - ->method('downloadPackages') - ->with(self::anything()) - ->willReturn([ - 'bootstrap' => [ - 'content' => 'bootstrap content...', - 'dependencies' => ['@popperjs/core'], - 'extraFiles' => [], - ], - ]); - - $leafletPackageResolver = new LeafletPackageResolver( - $innerPackageResolver, - new MockHttpClient() - ); - - $downloadedPackages = $leafletPackageResolver->downloadPackages([ - 'bootstrap' => ImportMapEntry::createRemote( - importName: 'bootstrap', - importMapType: ImportMapType::JS, - path: '/project/assets/vendor/bootstrap/bootstrap.index.js', - version: '5.3.3', - packageModuleSpecifier: 'bootstrap', - isEntrypoint: false - ), - ]); - - self::assertSame([ - 'bootstrap' => [ - 'content' => 'bootstrap content...', - 'dependencies' => ['@popperjs/core'], - 'extraFiles' => [], - ], - ], $downloadedPackages); - } - - /** - * @dataProvider provideResolverShouldResolveLeafletExtraDependencies - */ - public function testResolverShouldResolveLeafletExtraDependencies( - array $importMapEntries, - array $innerPackageResolverDownloadedPackages, - callable $httpClientResponseFactory, - array $expectedDownloadedPackages, - ): void { - $innerPackageResolver = $this->createMock(PackageResolverInterface::class); - $innerPackageResolver->expects(self::once()) - ->method('downloadPackages') - ->with(self::anything()) - ->willReturn($innerPackageResolverDownloadedPackages); - - $leafletPackageResolver = new LeafletPackageResolver( - $innerPackageResolver, - new MockHttpClient($httpClientResponseFactory) - ); - - $downloadedPackages = $leafletPackageResolver->downloadPackages($importMapEntries); - - self::assertSame($expectedDownloadedPackages, $downloadedPackages); - } - - /** - * @return iterable - */ - public function provideResolverShouldResolveLeafletExtraDependencies(): iterable - { - yield 'leaflet 1.9.4' => [ - 'import_map_entries' => [ - 'leaflet' => ImportMapEntry::createRemote( - importName: 'leaflet', - importMapType: ImportMapType::JS, - path: '/project/assets/vendor/leaflet/leaflet.index.js', - version: '1.9.4', - packageModuleSpecifier: 'leaflet', - isEntrypoint: false - ), - ], - 'inner_package_resolver_downloaded_packages' => [ - 'leaflet' => [ - 'content' => file_get_contents(__DIR__.'/../Fixtures/leaflet.1.9.4.js'), - 'dependencies' => [], - 'extraFiles' => [], - ], - ], - 'http_client_response_factory' => function (string $method, string $url, array $options) { - return match ($url) { - 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/images/marker-icon.png' => new MockResponse('marker-icon.png content...'), - 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/images/marker-icon-2x.png' => new MockResponse('marker-icon-2x.png content...'), - 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/images/marker-shadow.png' => new MockResponse('marker-shadow.png content...'), - default => throw new \RuntimeException("Unexpected URL: $url"), - }; - }, - 'expected_downloaded_packages' => [ - 'leaflet' => [ - 'content' => file_get_contents(__DIR__.'/../Fixtures/leaflet.1.9.4.js'), - 'dependencies' => [], - 'extraFiles' => [ - 'dist/images/marker-icon.png' => 'marker-icon.png content...', - 'dist/images/marker-icon-2x.png' => 'marker-icon-2x.png content...', - 'dist/images/marker-shadow.png' => 'marker-shadow.png content...', - ], - ], - ], - ]; - } -} diff --git a/src/Map/tests/InfoWindowTest.php b/src/Map/tests/InfoWindowTest.php index 9ba9a41076c..8e55df27e39 100644 --- a/src/Map/tests/InfoWindowTest.php +++ b/src/Map/tests/InfoWindowTest.php @@ -27,7 +27,7 @@ public function testToArray(): void autoClose: false, ); - $this->assertSame([ + 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' => [ diff --git a/src/Map/tests/Kernel/FrameworkAppKernel.php b/src/Map/tests/Kernel/FrameworkAppKernel.php index cf0f33cc5f8..1246a8d8d13 100644 --- a/src/Map/tests/Kernel/FrameworkAppKernel.php +++ b/src/Map/tests/Kernel/FrameworkAppKernel.php @@ -36,7 +36,9 @@ 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', ['provider' => 'leaflet://default']); + $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 index e1ca404aeff..364b843d518 100644 --- a/src/Map/tests/Kernel/TwigAppKernel.php +++ b/src/Map/tests/Kernel/TwigAppKernel.php @@ -38,9 +38,9 @@ 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', ['provider' => 'leaflet://default']); + $container->loadFromExtension('ux_map', []); - $container->setAlias('test.ux_map.map_factory', 'ux_map.map_factory')->setPublic(true); + $container->setAlias('test.ux_map.renderers', 'ux_map.renderers')->setPublic(true); }); } } diff --git a/src/Map/tests/LatLngTest.php b/src/Map/tests/LatLngTest.php index 819ad17d8b7..878b0a45e42 100644 --- a/src/Map/tests/LatLngTest.php +++ b/src/Map/tests/LatLngTest.php @@ -30,8 +30,8 @@ public static function provideInvalidLatLng(): iterable */ public function testInvalidLatLng(float $latitude, float $longitude, string $expectedExceptionMessage): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($expectedExceptionMessage); + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage($expectedExceptionMessage); new LatLng($latitude, $longitude); } diff --git a/src/Map/tests/MapFactoryTest.php b/src/Map/tests/MapFactoryTest.php deleted file mode 100644 index 706aded5137..00000000000 --- a/src/Map/tests/MapFactoryTest.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * 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\MapFactory; -use Symfony\UX\Map\MapRegistry; -use Symfony\UX\Map\Provider\ProviderInterface; -use Symfony\UX\Map\Tests\Provider\DummyProvider; - -final class MapFactoryTest extends TestCase -{ - private MapFactory $mapFactory; - private ProviderInterface $defaultProvider; - private MapRegistry $mapRegistry; - - protected function setUp(): void - { - $this->mapFactory = new MapFactory( - $this->defaultProvider = new DummyProvider(), - $this->mapRegistry = new MapRegistry(), - ); - - self::assertEmpty($this->mapRegistry->all()); - } - - public function testCreateMap(): void - { - $map = $this->mapFactory->createMap(); - - self::assertSame($this->defaultProvider, $map->getProvider()); - self::assertContains($map, $this->mapRegistry->all()); - } -} diff --git a/src/Map/tests/MapRegistryTest.php b/src/Map/tests/MapRegistryTest.php deleted file mode 100644 index 03df79c6a96..00000000000 --- a/src/Map/tests/MapRegistryTest.php +++ /dev/null @@ -1,35 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Tests\Registry; - -use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Map; -use Symfony\UX\Map\MapRegistry; -use Symfony\UX\Map\Tests\Provider\DummyProvider; - -final class MapRegistryTest extends TestCase -{ - public function testBehavior(): void - { - $mapRegistry = new MapRegistry(); - self::assertEmpty($mapRegistry->all()); - - $mapRegistry->register($map1 = new Map(new DummyProvider(), DummyProvider::getDefaultMapOptions())); - self::assertSame([$map1], $mapRegistry->all()); - - $mapRegistry->register($map2 = new Map(new DummyProvider(), DummyProvider::getDefaultMapOptions())); - self::assertSame([$map1, $map2], $mapRegistry->all()); - - $mapRegistry->reset(); - self::assertEmpty($mapRegistry->all()); - } -} diff --git a/src/Map/tests/MapTest.php b/src/Map/tests/MapTest.php index 3b1daec0fd0..10a72be92f9 100644 --- a/src/Map/tests/MapTest.php +++ b/src/Map/tests/MapTest.php @@ -16,8 +16,8 @@ use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\LatLng; use Symfony\UX\Map\Map; +use Symfony\UX\Map\MapOptionsInterface; use Symfony\UX\Map\Marker; -use Symfony\UX\Map\Tests\Provider\DummyProvider; class MapTest extends TestCase { @@ -26,7 +26,7 @@ public function testCenterValidation(): void self::expectException(InvalidArgumentException::class); self::expectExceptionMessage('The center of the map must be set.'); - $map = new Map(new DummyProvider(), DummyProvider::getDefaultMapOptions()); + $map = new Map(); $map->toArray(); } @@ -35,104 +35,87 @@ public function testZoomValidation(): void self::expectException(InvalidArgumentException::class); self::expectExceptionMessage('The zoom of the map must be set.'); - $map = new Map(new DummyProvider(), DummyProvider::getDefaultMapOptions()); - $map->center(new LatLng(48.8566, 2.3522)); + $map = new Map( + center: new LatLng(48.8566, 2.3522) + ); $map->toArray(); } public function testWithMinimumConfiguration(): void { - $map = new Map(new DummyProvider(), DummyProvider::getDefaultMapOptions()); + $map = new Map(); $map ->center(new LatLng(48.8566, 2.3522)) ->zoom(6); + $array = $map->toArray(); + self::assertSame([ 'center' => ['lat' => 48.8566, 'lng' => 2.3522], 'zoom' => 6.0, 'fitBoundsToMarkers' => false, - 'options' => [], + 'options' => $array['options'], 'markers' => [], - 'infoWindows' => [], - ], $map->toArray()); + ], $array); } public function testWithMaximumConfiguration(): void { - $map = new Map(new DummyProvider(), DummyProvider::getDefaultMapOptions()); + $map = new Map(); $map ->center(new LatLng(48.8566, 2.3522)) ->zoom(6) ->fitBoundsToMarkers() - ->options(DummyProvider::getDefaultMapOptions()) + ->options(new class() implements MapOptionsInterface { + public function toArray(): array + { + return [ + 'mapTypeId' => 'roadmap', + ]; + } + }) ->addMarker(new Marker( position: new LatLng(48.8566, 2.3522), title: 'Paris', - infoWindow: new InfoWindow(headerContent: 'Paris', content: 'Paris !', position: new LatLng(48.8566, 2.3522), opened: true) + infoWindow: new InfoWindow(headerContent: 'Paris', content: 'Paris', position: new LatLng(48.8566, 2.3522)) )) ->addMarker(new Marker( position: new LatLng(45.764, 4.8357), title: 'Lyon', - infoWindow: new InfoWindow(headerContent: 'Lyon', content: 'Lyon !', position: new LatLng(45.764, 4.8357), opened: true) + infoWindow: new InfoWindow(headerContent: 'Lyon', content: 'Lyon', position: new LatLng(45.764, 4.8357), opened: true) )) ->addMarker(new Marker( position: new LatLng(43.2965, 5.3698), title: 'Marseille', - infoWindow: new InfoWindow(headerContent: 'Marseille', content: 'Marseille !', position: new LatLng(43.2965, 5.3698), opened: true) - )) - ->addMarker(new Marker( - position: new LatLng(43.7102, 7.262), - title: 'Nice', - infoWindow: new InfoWindow(headerContent: 'Nice', content: 'Nice !', position: new LatLng(43.7102, 7.262), opened: true) - )) - ->addMarker(new Marker( - position: new LatLng(47.2184, -1.5536), - title: 'Nantes', - infoWindow: new InfoWindow(headerContent: 'Nantes', content: 'Nantes !', position: new LatLng(47.2184, -1.5536), opened: true) - )) - ->addInfoWindow(new InfoWindow(headerContent: 'Strasbourg', content: 'Strasbourg !', position: new LatLng(48.5734, 7.7521), opened: true)); + infoWindow: new InfoWindow(headerContent: 'Marseille', content: 'Marseille', position: new LatLng(43.2965, 5.3698), opened: true) + )); + + $array = $map->toArray(); self::assertSame([ 'center' => ['lat' => 48.8566, 'lng' => 2.3522], 'zoom' => 6.0, 'fitBoundsToMarkers' => true, - 'options' => [], + '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' => true, 'autoClose' => true], + '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], + '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], - ], - [ - 'position' => ['lat' => 43.7102, 'lng' => 7.262], - 'title' => 'Nice', - 'infoWindow' => ['headerContent' => 'Nice', 'content' => 'Nice !', 'position' => ['lat' => 43.7102, 'lng' => 7.262], 'opened' => true, 'autoClose' => true], - ], - [ - 'position' => ['lat' => 47.2184, 'lng' => -1.5536], - 'title' => 'Nantes', - 'infoWindow' => ['headerContent' => 'Nantes', 'content' => 'Nantes !', 'position' => ['lat' => 47.2184, 'lng' => -1.5536], 'opened' => true, 'autoClose' => true], + 'infoWindow' => ['headerContent' => 'Marseille', 'content' => 'Marseille', 'position' => ['lat' => 43.2965, 'lng' => 5.3698], 'opened' => true, 'autoClose' => true], ], ], - 'infoWindows' => [ - [ - 'headerContent' => 'Strasbourg', - 'content' => 'Strasbourg !', - 'position' => ['lat' => 48.5734, 'lng' => 7.7521], - 'opened' => true, - 'autoClose' => true, - ], - ], - ], $map->toArray()); + ], $array); + + self::assertSame('roadmap', $array['options']->mapTypeId); } } diff --git a/src/Map/tests/MarkerTest.php b/src/Map/tests/MarkerTest.php index 45e13389435..a58935999a1 100644 --- a/src/Map/tests/MarkerTest.php +++ b/src/Map/tests/MarkerTest.php @@ -24,7 +24,7 @@ public function testToArray(): void position: new LatLng(48.8566, 2.3522), ); - $this->assertSame([ + self::assertSame([ 'position' => ['lat' => 48.8566, 'lng' => 2.3522], 'title' => null, 'infoWindow' => null, @@ -40,7 +40,7 @@ public function testToArray(): void ), ); - $this->assertSame([ + self::assertSame([ 'position' => ['lat' => 48.8566, 'lng' => 2.3522], 'title' => 'Paris', 'infoWindow' => [ diff --git a/src/Map/tests/Provider/DummyProvider.php b/src/Map/tests/Provider/DummyProvider.php deleted file mode 100644 index c4c12bd9699..00000000000 --- a/src/Map/tests/Provider/DummyProvider.php +++ /dev/null @@ -1,46 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Tests\Provider; - -use Symfony\UX\Map\MapOptionsInterface; -use Symfony\UX\Map\Provider\ProviderInterface; -use Symfony\UX\Map\Provider\ProviderTrait; - -final readonly class DummyProvider implements ProviderInterface -{ - use ProviderTrait; - - public function getName(): string - { - return 'dummy'; - } - - public function __toString() - { - return 'dummy://default'; - } - - public static function getDefaultMapOptions(): MapOptionsInterface - { - return new class() implements MapOptionsInterface { - public function toArray(): array - { - return []; - } - }; - } - - public function getOptions(): array - { - return []; - } -} diff --git a/src/Map/tests/Provider/GoogleMaps/GoogleMapsOptionsTest.php b/src/Map/tests/Provider/GoogleMaps/GoogleMapsOptionsTest.php deleted file mode 100644 index a52f9b96bd5..00000000000 --- a/src/Map/tests/Provider/GoogleMaps/GoogleMapsOptionsTest.php +++ /dev/null @@ -1,102 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Tests\Provider\GoogleMaps; - -use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Provider\GoogleMaps\GoogleMapsOptions; -use Symfony\UX\Map\Provider\GoogleMaps\Option as GoogleMapsOption; - -class GoogleMapsOptionsTest extends TestCase -{ - public function testWithMinimalConfiguration() - { - $options = new GoogleMapsOptions(); - - self::assertSame([ - 'mapId' => null, - 'gestureHandling' => 'auto', - 'backgroundColor' => null, - 'disableDoubleClickZoom' => false, - 'zoomControl' => true, - 'zoomControlOptions' => [ - 'position' => GoogleMapsOption\ControlPosition::INLINE_END_BLOCK_END->value, - ], - 'mapTypeControl' => true, - 'mapTypeControlOptions' => [ - 'mapTypeIds' => [], - 'position' => GoogleMapsOption\ControlPosition::BLOCK_START_INLINE_START->value, - 'style' => GoogleMapsOption\MapTypeControlStyle::DEFAULT->value, - ], - 'streetViewControl' => true, - 'streetViewControlOptions' => [ - 'position' => GoogleMapsOption\ControlPosition::INLINE_END_BLOCK_END->value, - ], - 'fullscreenControl' => true, - 'fullscreenControlOptions' => [ - 'position' => GoogleMapsOption\ControlPosition::INLINE_END_BLOCK_START->value, - ], - ], $options->toArray()); - } - - public function testWithMaximumConfiguration() - { - $options = new GoogleMapsOptions( - mapId: '2b2d73ba4b8c7b41', - gestureHandling: GoogleMapsOption\GestureHandling::Greedy, - backgroundColor: '#f00', - disableDoubleClickZoom: true, - zoomControl: false, - zoomControlOptions: new GoogleMapsOption\ZoomControlOptions( - position: GoogleMapsOption\ControlPosition::BLOCK_START_INLINE_END, - ), - mapTypeControl: false, - mapTypeControlOptions: new GoogleMapsOption\MapTypeControlOptions( - mapTypeIds: [GoogleMapsOption\MapTypeId::HYBRID, GoogleMapsOption\MapTypeId::ROADMAP], - position: GoogleMapsOption\ControlPosition::INLINE_END_BLOCK_START, - style: GoogleMapsOption\MapTypeControlStyle::DROPDOWN_MENU, - ), - streetViewControl: false, - streetViewControlOptions: new GoogleMapsOption\StreetViewControlOptions( - position: GoogleMapsOption\ControlPosition::BLOCK_END_INLINE_START, - ), - fullscreenControl: false, - fullscreenControlOptions: new GoogleMapsOption\FullscreenControlOptions( - position: GoogleMapsOption\ControlPosition::INLINE_START_BLOCK_END, - ), - ); - - self::assertSame([ - 'mapId' => '2b2d73ba4b8c7b41', - 'gestureHandling' => GoogleMapsOption\GestureHandling::Greedy->value, - 'backgroundColor' => '#f00', - 'disableDoubleClickZoom' => true, - 'zoomControl' => false, - 'zoomControlOptions' => [ - 'position' => GoogleMapsOption\ControlPosition::BLOCK_START_INLINE_END->value, - ], - 'mapTypeControl' => false, - 'mapTypeControlOptions' => [ - 'mapTypeIds' => [GoogleMapsOption\MapTypeId::HYBRID->value, GoogleMapsOption\MapTypeId::ROADMAP->value], - 'position' => GoogleMapsOption\ControlPosition::INLINE_END_BLOCK_START->value, - 'style' => GoogleMapsOption\MapTypeControlStyle::DROPDOWN_MENU->value, - ], - 'streetViewControl' => false, - 'streetViewControlOptions' => [ - 'position' => GoogleMapsOption\ControlPosition::BLOCK_END_INLINE_START->value, - ], - 'fullscreenControl' => false, - 'fullscreenControlOptions' => [ - 'position' => GoogleMapsOption\ControlPosition::INLINE_START_BLOCK_END->value, - ], - ], $options->toArray()); - } -} diff --git a/src/Map/tests/Provider/GoogleMaps/GoogleMapsProviderTest.php b/src/Map/tests/Provider/GoogleMaps/GoogleMapsProviderTest.php deleted file mode 100644 index e46d1b26c45..00000000000 --- a/src/Map/tests/Provider/GoogleMaps/GoogleMapsProviderTest.php +++ /dev/null @@ -1,76 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Tests\Provider\GoogleMaps; - -use Symfony\UX\Map\Provider\GoogleMaps\GoogleMapsProvider; -use Symfony\UX\Map\Test\ProviderTestCase; - -class GoogleMapsProviderTest extends ProviderTestCase -{ - public function testGetName(): void - { - $provider = new GoogleMapsProvider('api_key'); - - self::assertSame('google-maps', $provider->getName()); - } - - public function provideTestOptions(): iterable - { - yield [ - 'provider' => new GoogleMapsProvider('api_key'), - 'expectedToString' => 'google-maps://*******@default/?', - 'expectedOptions' => ['apiKey' => 'api_key'], - ]; - - yield [ - 'provider' => new GoogleMapsProvider('api_key', id: 'gmap'), - 'expectedToString' => 'google-maps://*******@default/?id=gmap', - 'expectedOptions' => ['id' => 'gmap', 'apiKey' => 'api_key'], - ]; - - yield [ - 'provider' => new GoogleMapsProvider('api_key', id: 'gmap', language: 'fr'), - 'expectedToString' => 'google-maps://*******@default/?id=gmap&language=fr', - 'expectedOptions' => ['id' => 'gmap', 'language' => 'fr', 'apiKey' => 'api_key'], - ]; - - yield [ - 'provider' => new GoogleMapsProvider('api_key', id: 'gmap', language: 'fr', region: 'FR'), - 'expectedToString' => 'google-maps://*******@default/?id=gmap&language=fr®ion=FR', - 'expectedOptions' => ['id' => 'gmap', 'language' => 'fr', 'region' => 'FR', 'apiKey' => 'api_key'], - ]; - - yield [ - 'provider' => new GoogleMapsProvider('api_key', id: 'gmap', language: 'fr', region: 'FR', nonce: 'abcd'), - 'expectedToString' => 'google-maps://*******@default/?id=gmap&language=fr®ion=FR&nonce=abcd', - 'expectedOptions' => ['id' => 'gmap', 'language' => 'fr', 'region' => 'FR', 'nonce' => 'abcd', 'apiKey' => 'api_key'], - ]; - - yield [ - 'provider' => new GoogleMapsProvider('api_key', id: 'gmap', language: 'fr', region: 'FR', nonce: 'abcd', retries: 10), - 'expectedToString' => 'google-maps://*******@default/?id=gmap&language=fr®ion=FR&nonce=abcd&retries=10', - 'expectedOptions' => ['id' => 'gmap', 'language' => 'fr', 'region' => 'FR', 'nonce' => 'abcd', 'retries' => 10, 'apiKey' => 'api_key'], - ]; - - yield [ - 'provider' => new GoogleMapsProvider('api_key', id: 'gmap', language: 'fr', region: 'FR', nonce: 'abcd', retries: 10, url: 'https://maps.googleapis.com/maps/api/js'), - 'expectedToString' => 'google-maps://*******@default/?id=gmap&language=fr®ion=FR&nonce=abcd&retries=10&url=https%3A%2F%2Fmaps.googleapis.com%2Fmaps%2Fapi%2Fjs', - 'expectedOptions' => ['id' => 'gmap', 'language' => 'fr', 'region' => 'FR', 'nonce' => 'abcd', 'retries' => 10, 'url' => 'https://maps.googleapis.com/maps/api/js', 'apiKey' => 'api_key'], - ]; - - yield [ - 'provider' => new GoogleMapsProvider('api_key', id: 'gmap', language: 'fr', region: 'FR', nonce: 'abcd', retries: 10, url: 'https://maps.googleapis.com/maps/api/js', version: 'weekly'), - 'expectedToString' => 'google-maps://*******@default/?id=gmap&language=fr®ion=FR&nonce=abcd&retries=10&url=https%3A%2F%2Fmaps.googleapis.com%2Fmaps%2Fapi%2Fjs&version=weekly', - 'expectedOptions' => ['id' => 'gmap', 'language' => 'fr', 'region' => 'FR', 'nonce' => 'abcd', 'retries' => 10, 'url' => 'https://maps.googleapis.com/maps/api/js', 'version' => 'weekly', 'apiKey' => 'api_key'], - ]; - } -} diff --git a/src/Map/tests/Provider/GoogleMaps/GoogleProviderFactoryTest.php b/src/Map/tests/Provider/GoogleMaps/GoogleProviderFactoryTest.php deleted file mode 100644 index ccc59a90b17..00000000000 --- a/src/Map/tests/Provider/GoogleMaps/GoogleProviderFactoryTest.php +++ /dev/null @@ -1,48 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Tests\Provider\GoogleMaps; - -use Symfony\UX\Map\Provider\GoogleMaps\GoogleMapsProviderFactory; -use Symfony\UX\Map\Provider\ProviderFactoryInterface; -use Symfony\UX\Map\Test\ProviderFactoryTestCase; - -final class GoogleProviderFactoryTest extends ProviderFactoryTestCase -{ - public function createFactory(): ProviderFactoryInterface - { - return new GoogleMapsProviderFactory(); - } - - public static function supportsProvider(): iterable - { - yield [true, 'google-maps://GOOGLE_MAPS_API_KEY@default']; - yield [false, 'somethingElse://login:apiKey@default']; - } - - public static function createProvider(): iterable - { - yield [ - 'google-maps://*******************@default/?version=weekly', - 'google-maps://GOOGLE_MAPS_API_KEY@default', - ]; - - yield [ - 'google-maps://*******************@default/?version=quartly', - 'google-maps://GOOGLE_MAPS_API_KEY@default?version=quartly', - ]; - } - - public static function unsupportedSchemeProvider(): iterable - { - yield ['somethingElse://foo@default']; - } -} diff --git a/src/Map/tests/Provider/GoogleMaps/Option/GestureHandlingTest.php b/src/Map/tests/Provider/GoogleMaps/Option/GestureHandlingTest.php deleted file mode 100644 index bb0a21e2f0e..00000000000 --- a/src/Map/tests/Provider/GoogleMaps/Option/GestureHandlingTest.php +++ /dev/null @@ -1,26 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Tests\Provider\GoogleMaps\Option; - -use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Provider\GoogleMaps\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/tests/Provider/GoogleMaps/Option/MapTypeIdTest.php b/src/Map/tests/Provider/GoogleMaps/Option/MapTypeIdTest.php deleted file mode 100644 index 53a949c9f39..00000000000 --- a/src/Map/tests/Provider/GoogleMaps/Option/MapTypeIdTest.php +++ /dev/null @@ -1,26 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Tests\Provider\GoogleMaps\Option; - -use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Provider\GoogleMaps\Option\MapTypeId; - -class MapTypeIdTest extends TestCase -{ - public function testEnumValues(): void - { - self::assertSame('hybrid', MapTypeId::HYBRID->value); - self::assertSame('roadmap', MapTypeId::ROADMAP->value); - self::assertSame('satellite', MapTypeId::SATELLITE->value); - self::assertSame('terrain', MapTypeId::TERRAIN->value); - } -} diff --git a/src/Map/tests/Provider/Leaflet/LeafletProviderFactoryTest.php b/src/Map/tests/Provider/Leaflet/LeafletProviderFactoryTest.php deleted file mode 100644 index c6692cf9390..00000000000 --- a/src/Map/tests/Provider/Leaflet/LeafletProviderFactoryTest.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Tests\Provider\Leaflet; - -use Symfony\UX\Map\Provider\Leaflet\LeafletProviderFactory; -use Symfony\UX\Map\Provider\ProviderFactoryInterface; -use Symfony\UX\Map\Test\ProviderFactoryTestCase; - -final class LeafletProviderFactoryTest extends ProviderFactoryTestCase -{ - public function createFactory(): ProviderFactoryInterface - { - return new LeafletProviderFactory(); - } - - public static function supportsProvider(): iterable - { - yield [true, 'leaflet://default']; - yield [false, 'foo://default']; - } - - public static function createProvider(): iterable - { - yield [ - 'leaflet://default', - 'leaflet://default', - ]; - } - - public static function unsupportedSchemeProvider(): iterable - { - yield ['somethingElse://foo@default']; - } -} diff --git a/src/Map/tests/Provider/Leaflet/LeafletProviderTest.php b/src/Map/tests/Provider/Leaflet/LeafletProviderTest.php deleted file mode 100644 index bb22e3b60fc..00000000000 --- a/src/Map/tests/Provider/Leaflet/LeafletProviderTest.php +++ /dev/null @@ -1,34 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Tests\Provider\Leaflet; - -use Symfony\UX\Map\Provider\Leaflet\LeafletProvider; -use Symfony\UX\Map\Test\ProviderTestCase; - -class LeafletProviderTest extends ProviderTestCase -{ - public function testGetName(): void - { - $leafletProvider = new LeafletProvider(); - - self::assertEquals('leaflet', $leafletProvider->getName()); - } - - public function provideTestOptions(): iterable - { - yield [ - new LeafletProvider(), - 'leaflet://default', - [], - ]; - } -} diff --git a/src/Map/tests/Provider/ProviderTest.php b/src/Map/tests/Provider/ProviderTest.php deleted file mode 100644 index 290b28b7726..00000000000 --- a/src/Map/tests/Provider/ProviderTest.php +++ /dev/null @@ -1,46 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Tests\Provider; - -use PHPUnit\Framework\TestCase; -use Symfony\UX\Map\Exception\UnsupportedSchemeException; -use Symfony\UX\Map\Provider\Provider; -use Symfony\UX\Map\Provider\ProviderFactoryInterface; -use Symfony\UX\Map\Provider\ProviderInterface; - -final class ProviderTest extends TestCase -{ - public function testUnsupportedSchemeException(): void - { - $this->expectException(UnsupportedSchemeException::class); - $this->expectExceptionMessage('The provider "scheme" is not supported.'); - - $provider = new Provider([]); - $provider->fromString('scheme://default'); - } - - public function testSupportedFactory(): void - { - $provider = new Provider([ - 'one' => $oneFactory = $this->createMock(ProviderFactoryInterface::class), - 'two' => $twoFactory = $this->createMock(ProviderFactoryInterface::class), - ]); - - $oneFactory->expects($this->once())->method('supports')->willReturn(false); - $twoFactory->expects($this->once())->method('supports')->willReturn(true); - $twoFactory->expects($this->once())->method('create')->willReturn($twoProvider = $this->createMock(ProviderInterface::class)); - - $provider = $provider->fromString('scheme://default'); - - self::assertSame($twoProvider, $provider); - } -} diff --git a/src/Map/tests/Provider/DsnTest.php b/src/Map/tests/Renderer/DsnTest.php similarity index 71% rename from src/Map/tests/Provider/DsnTest.php rename to src/Map/tests/Renderer/DsnTest.php index 3e73f54c2ce..52766a4318a 100644 --- a/src/Map/tests/Provider/DsnTest.php +++ b/src/Map/tests/Renderer/DsnTest.php @@ -9,29 +9,29 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Map\Tests\Provider; +namespace Symfony\UX\Map\Tests\Renderer; use PHPUnit\Framework\TestCase; use Symfony\UX\Map\Exception\InvalidArgumentException; -use Symfony\UX\Map\Provider\Dsn; +use Symfony\UX\Map\Renderer\Dsn; final class DsnTest extends TestCase { /** - * @dataProvider constructProvider + * @dataProvider constructDsn */ public function testConstruct(string $dsnString, string $scheme, string $host, ?string $user = null, array $options = [], ?string $path = null) { $dsn = new Dsn($dsnString); - $this->assertSame($dsnString, $dsn->getOriginalDsn()); + self::assertSame($dsnString, $dsn->getOriginalDsn()); - $this->assertSame($scheme, $dsn->getScheme()); - $this->assertSame($host, $dsn->getHost()); - $this->assertSame($user, $dsn->getUser()); - $this->assertSame($options, $dsn->getOptions()); + self::assertSame($scheme, $dsn->getScheme()); + self::assertSame($host, $dsn->getHost()); + self::assertSame($user, $dsn->getUser()); + self::assertSame($options, $dsn->getOptions()); } - public static function constructProvider(): iterable + public static function constructDsn(): iterable { yield 'simple dsn' => [ 'scheme://default', @@ -78,26 +78,26 @@ public static function constructProvider(): iterable } /** - * @dataProvider invalidDsnProvider + * @dataProvider invalidDsn */ public function testInvalidDsn(string $dsnString, string $exceptionMessage) { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($exceptionMessage); + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage($exceptionMessage); new Dsn($dsnString); } - public static function invalidDsnProvider(): iterable + public static function invalidDsn(): iterable { yield [ 'leaflet://', - 'The map provider DSN is invalid.', + 'The map renderer DSN is invalid.', ]; yield [ '//default', - 'The map provider DSN must contain a scheme.', + '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 index d8ba5583a5a..6864f633036 100644 --- a/src/Map/tests/TwigTest.php +++ b/src/Map/tests/TwigTest.php @@ -12,95 +12,46 @@ namespace Symfony\UX\Map\Tests; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\KernelInterface; -use Symfony\UX\Map\LatLng; -use Symfony\UX\Map\Provider\Leaflet\LeafletProvider; +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 createKernel(array $options = []): KernelInterface + protected static function getKernelClass(): string { - return new class('test', true) extends TwigAppKernel { - public function registerContainerConfiguration(LoaderInterface $loader) - { - parent::registerContainerConfiguration($loader); - - $loader->load(function (ContainerBuilder $container) { - $container->loadFromExtension('ux_map', [ - 'provider' => 'google-maps://GOOGLE_MAPS_API_KEY@default', - ]); - }); - } - }; - } - - protected function setUp(): void - { - self::bootKernel(); - } - - public function testRenderScriptTagsShouldOutputNothingIfNoMapsHaveBeenCreated(): void - { - $twig = self::getContainer()->get('twig'); - $template = $twig->createTemplate('{{ ux_map_script_tags() }}'); - - self::assertEmpty($template->render()); - } - - public function testRenderScriptTagsShouldOutputTheScriptTagsForTheMaps(): void - { - $twig = self::getContainer()->get('twig'); - $mapFactory = self::getContainer()->get('ux_map.map_factory'); - - $mapFactory->createMap(); - - self::assertStringContainsString( - '', - $twig->createTemplate('{{ ux_map_script_tags() }}')->render() - ); - - $mapFactory->createMap(); - - self::assertStringContainsString( - '', - $twig->createTemplate('{{ ux_map_script_tags() }}')->render(), - 'The script tags should only be rendered once per provider' - ); - - $mapFactory->createMap(new LeafletProvider()); - - self::assertStringContainsString( - '', - $twig->createTemplate('{{ ux_map_script_tags() }}')->render(), - '' - ); + return TwigAppKernel::class; } public function testRenderMap(): void { - $twig = self::getContainer()->get('twig'); - $mapFactory = self::getContainer()->get('ux_map.map_factory'); + $map = new Map(); + $attributes = ['data-foo' => 'bar']; - $map = $mapFactory->createMap(); - $map - ->center(new LatLng(48.8566, 2.3522)) - ->zoom(12); + $renderer = self::createMock(RendererInterface::class); + $renderer + ->expects(self::once()) + ->method('renderMap') + ->with($map, $attributes) + ->willReturn('
') + ; - self::assertStringContainsString( - '
', - $twig->createTemplate('{{ render_map(map) }}')->render([ - 'map' => $map, - ]) - ); + self::getContainer()->set('test.ux_map.renderers', $renderer); - self::assertStringContainsString( - '
', - $twig->createTemplate('{{ render_map(map, { "data-controller": "my-map", "class": "foo" }) }}')->render([ - 'map' => $map, + /** @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/src/Map/tests/UxMapBundleTest.php b/src/Map/tests/UxMapBundleTest.php deleted file mode 100644 index 3c248ec4d8a..00000000000 --- a/src/Map/tests/UxMapBundleTest.php +++ /dev/null @@ -1,35 +0,0 @@ - - * - * 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\Component\HttpKernel\Kernel; -use Symfony\UX\Map\Tests\Kernel\FrameworkAppKernel; -use Symfony\UX\Map\Tests\Kernel\TwigAppKernel; - -class UxMapBundleTest extends TestCase -{ - public static function provideKernels() - { - yield 'framework' => [new FrameworkAppKernel('test', true)]; - yield 'twig' => [new TwigAppKernel('test', true)]; - } - - /** - * @dataProvider provideKernels - */ - public function testBootKernel(Kernel $kernel) - { - $kernel->boot(); - self::assertArrayHasKey('UXMapBundle', $kernel->getBundles()); - } -} 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 17f64ceaaa2..e2e4cfe9c59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2857,9 +2857,9 @@ integrity sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg== "@types/google.maps@^3.55.9": - version "3.55.9" - resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.55.9.tgz#3bbe1d044d9b999392a359fb37b0de2545ac53c4" - integrity sha512-phaOMtezbT3NaXPKiI3m0OosUS7Nly0auw3Be5s/CgMWLVoDAUP1Yb/Ld0TRoRp8ibrlT4VqM5kmzfvUA0UNLQ== + 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"