diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md index 33431733d59..d4894408a83 100644 --- a/src/Map/CHANGELOG.md +++ b/src/Map/CHANGELOG.md @@ -2,8 +2,11 @@ ## 2.20 -- Rename `render_map` Twig function `ux_map` -- Deprecate `render_map` Twig function +- Deprecate `render_map` Twig function (will be removed in 2.21). Use + `ux_map` or the `` Twig component instead. +- Add `ux_map` Twig function (replaces `render_map` with a more flexible + interface) +- Add `` Twig component ## 2.19 diff --git a/src/Map/composer.json b/src/Map/composer.json index 73d39303f74..a24b4fd3ca9 100644 --- a/src/Map/composer.json +++ b/src/Map/composer.json @@ -39,7 +39,8 @@ "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/twig-bundle": "^6.4|^7.0", + "symfony/ux-twig-component": "^2.18" }, "extra": { "thanks": { diff --git a/src/Map/config/services.php b/src/Map/config/services.php index 7dada5ec563..c94a9043cd9 100644 --- a/src/Map/config/services.php +++ b/src/Map/config/services.php @@ -15,6 +15,7 @@ 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 @@ -26,7 +27,6 @@ ->args([ abstract_arg('renderers configuration'), ]) - ->tag('twig.runtime') ->set('ux_map.renderer_factory.abstract', AbstractRendererFactory::class) ->abstract() @@ -41,5 +41,11 @@ ->set('ux_map.twig_extension', MapExtension::class) ->tag('twig.extension') + + ->set('ux_map.twig_runtime', MapRuntime::class) + ->args([ + service('ux_map.renderers'), + ]) + ->tag('twig.runtime') ; }; diff --git a/src/Map/config/twig_component.php b/src/Map/config/twig_component.php new file mode 100644 index 00000000000..f09999e9b5c --- /dev/null +++ b/src/Map/config/twig_component.php @@ -0,0 +1,32 @@ + + * + * 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\Twig\UXMapComponent; +use Symfony\UX\Map\Twig\UXMapComponentListener; +use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent; + +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('.ux_map.twig_component_listener', UXMapComponentListener::class) + ->args([ + service('ux_map.twig_runtime'), + ]) + ->tag('kernel.event_listener', [ + 'event' => PreCreateForRenderEvent::class, + 'method' => 'onPreCreateForRender', + ]) + + ->set('.ux_map.twig_component.map', UXMapComponent::class) + ->tag('twig.component', ['key' => 'UX:Map']) + ; +}; diff --git a/src/Map/src/InfoWindow.php b/src/Map/src/InfoWindow.php index 4897b3c9cd2..f98fbb6995e 100644 --- a/src/Map/src/InfoWindow.php +++ b/src/Map/src/InfoWindow.php @@ -19,7 +19,8 @@ final readonly class InfoWindow { /** - * @param array $extra Extra data, can be used by the developer to store additional information and use them later JavaScript side + * @param array $extra Extra data, can be used by the developer to store additional information and + * use them later JavaScript side */ public function __construct( private ?string $headerContent = null, @@ -31,6 +32,16 @@ public function __construct( ) { } + /** + * @return array{ + * headerContent: string|null, + * content: string|null, + * position: array{lat: float, lng: float}|null, + * opened: bool, + * autoClose: bool, + * extra: object, + * } + */ public function toArray(): array { return [ @@ -42,4 +53,25 @@ public function toArray(): array 'extra' => (object) $this->extra, ]; } + + /** + * @param array{ + * headerContent: string|null, + * content: string|null, + * position: array{lat: float, lng: float}|null, + * opened: bool, + * autoClose: bool, + * extra: object, + * } $data + * + * @internal + */ + public static function fromArray(array $data): self + { + if (isset($data['position'])) { + $data['position'] = Point::fromArray($data['position']); + } + + return new self(...$data); + } } diff --git a/src/Map/src/Map.php b/src/Map/src/Map.php index 834e0d3d299..d8fdf005e56 100644 --- a/src/Map/src/Map.php +++ b/src/Map/src/Map.php @@ -103,4 +103,36 @@ public function toArray(): array 'markers' => array_map(static fn (Marker $marker) => $marker->toArray(), $this->markers), ]; } + + /** + * @param array{ + * center?: array{lat: float, lng: float}, + * zoom?: float, + * markers?: list, + * fitBoundsToMarkers?: bool, + * options?: object, + * } $map + * + * @internal + */ + public static function fromArray(array $map): self + { + $map['fitBoundsToMarkers'] = true; + + if (isset($map['center'])) { + $map['center'] = Point::fromArray($map['center']); + } + + if (isset($map['zoom']) || isset($map['center'])) { + $map['fitBoundsToMarkers'] = false; + } + + $map['markers'] ??= []; + if (!\is_array($map['markers'])) { + throw new InvalidArgumentException('The "markers" parameter must be an array.'); + } + $map['markers'] = array_map(Marker::fromArray(...), $map['markers']); + + return new self(...$map); + } } diff --git a/src/Map/src/Marker.php b/src/Map/src/Marker.php index 5c822698b74..ac0dc0e0af6 100644 --- a/src/Map/src/Marker.php +++ b/src/Map/src/Marker.php @@ -11,6 +11,8 @@ namespace Symfony\UX\Map; +use Symfony\UX\Map\Exception\InvalidArgumentException; + /** * Represents a marker on a map. * @@ -19,7 +21,8 @@ final readonly class Marker { /** - * @param array $extra Extra data, can be used by the developer to store additional information and use them later JavaScript side + * @param array $extra Extra data, can be used by the developer to store additional information and + * use them later JavaScript side */ public function __construct( private Point $position, @@ -29,6 +32,14 @@ public function __construct( ) { } + /** + * @return array{ + * position: array{lat: float, lng: float}, + * title: string|null, + * infoWindow: array|null, + * extra: object, + * } + */ public function toArray(): array { return [ @@ -38,4 +49,28 @@ public function toArray(): array 'extra' => (object) $this->extra, ]; } + + /** + * @param array{ + * position: array{lat: float, lng: float}, + * title: string|null, + * infoWindow: array|null, + * extra: object, + * } $marker + * + * @internal + */ + public static function fromArray(array $marker): self + { + if (!isset($marker['position'])) { + throw new InvalidArgumentException('The "position" parameter is required.'); + } + $marker['position'] = Point::fromArray($marker['position']); + + if (isset($marker['infoWindow'])) { + $marker['infoWindow'] = InfoWindow::fromArray($marker['infoWindow']); + } + + return new self(...$marker); + } } diff --git a/src/Map/src/Point.php b/src/Map/src/Point.php index a6d71d88f69..f34f37a2387 100644 --- a/src/Map/src/Point.php +++ b/src/Map/src/Point.php @@ -43,4 +43,16 @@ public function toArray(): array 'lng' => $this->longitude, ]; } + + /** + * @param array{lat: float, lng: float}|array{0: float, 1: float} $point + */ + public static function fromArray(array $point): self + { + if (isset($point['lat'], $point['lng'])) { + return new self($point['lat'], $point['lng']); + } + + return new self(...$point); + } } diff --git a/src/Map/src/Twig/MapExtension.php b/src/Map/src/Twig/MapExtension.php index 57807f54c4e..6442ff1b340 100644 --- a/src/Map/src/Twig/MapExtension.php +++ b/src/Map/src/Twig/MapExtension.php @@ -11,7 +11,6 @@ namespace Symfony\UX\Map\Twig; -use Symfony\UX\Map\Renderer\Renderers; use Twig\DeprecatedCallableInfo; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -26,13 +25,13 @@ final class MapExtension extends AbstractExtension public function getFunctions(): array { return [ - new TwigFunction('render_map', [Renderers::class, 'renderMap'], [ + new TwigFunction('render_map', [MapRuntime::class, 'renderMap'], [ 'is_safe' => ['html'], ...(class_exists(DeprecatedCallableInfo::class) ? ['deprecation_info' => new DeprecatedCallableInfo('symfony/ux-map', '2.20', 'ux_map')] : ['deprecated' => '2.20', 'deprecating_package' => 'symfony/ux-map', 'alternative' => 'ux_map']), ]), - new TwigFunction('ux_map', [Renderers::class, 'renderMap'], ['is_safe' => ['html']]), + new TwigFunction('ux_map', [MapRuntime::class, 'renderMap'], ['is_safe' => ['html']]), ]; } } diff --git a/src/Map/src/Twig/MapRuntime.php b/src/Map/src/Twig/MapRuntime.php new file mode 100644 index 00000000000..62e50be86da --- /dev/null +++ b/src/Map/src/Twig/MapRuntime.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\Twig; + +use Symfony\UX\Map\Map; +use Symfony\UX\Map\Marker; +use Symfony\UX\Map\Point; +use Symfony\UX\Map\Renderer\RendererInterface; +use Twig\Extension\RuntimeExtensionInterface; + +/** + * @author Simon André + * + * @internal + */ +final class MapRuntime implements RuntimeExtensionInterface +{ + public function __construct( + private readonly RendererInterface $renderer, + ) { + } + + /** + * @param array $attributes + * @param array $markers + */ + public function renderMap( + ?Map $map = null, + array $attributes = [], + ?array $markers = null, + ?array $center = null, + ?float $zoom = null, + ): string { + if ($map instanceof Map) { + if (null !== $center || null !== $zoom || $markers) { + throw new \InvalidArgumentException('You cannot set "center", "markers" or "zoom" on an existing Map.'); + } + + return $this->renderer->renderMap($map, $attributes); + } + + $map = new Map(); + foreach ($markers ?? [] as $marker) { + $map->addMarker(Marker::fromArray($marker)); + } + if (null !== $center) { + $map->center(Point::fromArray($center)); + } + if (null !== $zoom) { + $map->zoom($zoom); + } + + return $this->renderer->renderMap($map, $attributes); + } +} diff --git a/src/Map/src/Twig/UXMapComponent.php b/src/Map/src/Twig/UXMapComponent.php new file mode 100644 index 00000000000..94cb6407e8f --- /dev/null +++ b/src/Map/src/Twig/UXMapComponent.php @@ -0,0 +1,32 @@ + + * + * 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\Marker; +use Symfony\UX\Map\Point; + +/** + * @author Simon André + * + * @internal + */ +final class UXMapComponent +{ + public ?float $zoom; + + public ?Point $center; + + /** + * @var Marker[] + */ + public array $markers; +} diff --git a/src/Map/src/Twig/UXMapComponentListener.php b/src/Map/src/Twig/UXMapComponentListener.php new file mode 100644 index 00000000000..3a36e5587da --- /dev/null +++ b/src/Map/src/Twig/UXMapComponentListener.php @@ -0,0 +1,42 @@ + + * + * 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\TwigComponent\Event\PreCreateForRenderEvent; + +/** + * @author Simon André + * + * @internal + */ +final class UXMapComponentListener +{ + public function __construct( + private MapRuntime $mapRuntime, + ) { + } + + public function onPreCreateForRender(PreCreateForRenderEvent $event): void + { + if ('ux:map' !== strtolower($event->getName())) { + return; + } + + $attributes = $event->getInputProps(); + $map = array_intersect_key($attributes, ['markers' => 0, 'center' => 1, 'zoom' => 2]); + $attributes = array_diff_key($attributes, $map); + + $html = $this->mapRuntime->renderMap(...$map, attributes: $attributes); + $event->setRenderedString($html); + $event->stopPropagation(); + } +} diff --git a/src/Map/src/UXMapBundle.php b/src/Map/src/UXMapBundle.php index 52ecec412b5..1392926131f 100644 --- a/src/Map/src/UXMapBundle.php +++ b/src/Map/src/UXMapBundle.php @@ -19,6 +19,7 @@ use Symfony\UX\Map\Bridge as MapBridge; use Symfony\UX\Map\Renderer\AbstractRendererFactory; use Symfony\UX\Map\Renderer\NullRendererFactory; +use Symfony\UX\TwigComponent\TwigComponentBundle; /** * @author Hugo Alliaume @@ -55,6 +56,10 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $config['renderer'] = 'null://null'; } + if (ContainerBuilder::willBeAvailable('symfony/ux-twig-component', TwigComponentBundle::class, ['symfony/ux-map'])) { + $container->import('../config/twig_component.php'); + } + if (str_starts_with($config['renderer'], 'null://')) { $container->services() ->set('ux_map.renderer_factory.null', NullRendererFactory::class) diff --git a/src/Map/tests/Kernel/TwigComponentKernel.php b/src/Map/tests/Kernel/TwigComponentKernel.php new file mode 100644 index 00000000000..7ee33360058 --- /dev/null +++ b/src/Map/tests/Kernel/TwigComponentKernel.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Kernel; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\Map\UXMapBundle; +use Symfony\UX\StimulusBundle\StimulusBundle; +use Symfony\UX\TwigComponent\TwigComponentBundle; + +/** + * @internal + */ +class TwigComponentKernel extends Kernel +{ + use AppKernelTrait; + + public function registerBundles(): iterable + { + return [ + new FrameworkBundle(), + new StimulusBundle(), + new TwigBundle(), + new TwigComponentBundle(), + new UXMapBundle(), + ]; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'secret' => '$ecret', + 'test' => true, + 'http_method_override' => false, + ]); + $container->loadFromExtension('twig', [ + 'default_path' => __DIR__.'/templates', + 'strict_variables' => true, + 'exception_controller' => null, + ]); + $container->loadFromExtension('twig_component', [ + 'defaults' => [], + 'anonymous_template_directory' => 'components', + ]); + $container->loadFromExtension('ux_map', []); + + $container->setAlias('test.ux_map.renderers', 'ux_map.renderers')->setPublic(true); + }); + } +} diff --git a/src/Map/tests/MapFactoryTest.php b/src/Map/tests/MapFactoryTest.php new file mode 100644 index 00000000000..a19e2d7996c --- /dev/null +++ b/src/Map/tests/MapFactoryTest.php @@ -0,0 +1,102 @@ + + * + * 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\Map; + +class MapFactoryTest extends TestCase +{ + public function testFromArray(): void + { + $array = self::createMapArray(); + $map = Map::fromArray($array); + + $this->assertEquals($array['center']['lat'], $map->toArray()['center']['lat']); + $this->assertEquals($array['center']['lng'], $map->toArray()['center']['lng']); + + $this->assertEquals((float) $array['zoom'], $map->toArray()['zoom']); + + $this->assertCount(1, $markers = $map->toArray()['markers']); + $this->assertEquals($array['markers'][0]['position']['lat'], $markers[0]['position']['lat']); + $this->assertEquals($array['markers'][0]['position']['lng'], $markers[0]['position']['lng']); + $this->assertSame($array['markers'][0]['title'], $markers[0]['title']); + $this->assertSame($array['markers'][0]['infoWindow']['headerContent'], $markers[0]['infoWindow']['headerContent']); + $this->assertSame($array['markers'][0]['infoWindow']['content'], $markers[0]['infoWindow']['content']); + } + + public function testFromArrayWithInvalidCenter(): void + { + $array = self::createMapArray(); + $array['center'] = 'invalid'; + + $this->expectException(\TypeError::class); + Map::fromArray($array); + } + + public function testFromArrayWithInvalidZoom(): void + { + $array = self::createMapArray(); + $array['zoom'] = 'invalid'; + + $this->expectException(\TypeError::class); + Map::fromArray($array); + } + + public function testFromArrayWithInvalidMarkers(): void + { + $array = self::createMapArray(); + $array['markers'] = 'invalid'; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "markers" parameter must be an array.'); + Map::fromArray($array); + } + + public function testFromArrayWithInvalidMarker(): void + { + $array = self::createMapArray(); + $array['markers'] = [ + [ + 'invalid', + ], + ]; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "position" parameter is required.'); + Map::fromArray($array); + } + + private static function createMapArray(): array + { + return [ + 'center' => [ + 'lat' => 48.8566, + 'lng' => 2.3522, + ], + 'zoom' => 12, + 'markers' => [ + [ + 'position' => [ + 'lat' => 48.8566, + 'lng' => 2.3522, + ], + 'title' => 'Paris', + 'infoWindow' => [ + 'headerContent' => 'Paris', + 'content' => 'Paris, the city of lights', + ], + ], + ], + ]; + } +} diff --git a/src/Map/tests/Twig/MapComponentTest.php b/src/Map/tests/Twig/MapComponentTest.php new file mode 100644 index 00000000000..031fd9293a3 --- /dev/null +++ b/src/Map/tests/Twig/MapComponentTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Twig; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Map\Map; +use Symfony\UX\Map\Point; +use Symfony\UX\Map\Renderer\RendererInterface; +use Symfony\UX\Map\Tests\Kernel\TwigComponentKernel; + +class MapComponentTest extends KernelTestCase +{ + protected static function getKernelClass(): string + { + return TwigComponentKernel::class; + } + + public function testRenderMapComponent(): void + { + $map = (new Map()) + ->center(new Point(latitude: 5, longitude: 10)) + ->zoom(4); + $attributes = ['data-foo' => 'bar']; + + $renderer = self::createMock(RendererInterface::class); + $renderer + ->method('renderMap') + ->with($map, $attributes) + ->willReturn('
') + ; + self::getContainer()->set('test.ux_map.renderers', $renderer); + + $twig = self::getContainer()->get('twig'); + $template = $twig->createTemplate(''); + + $this->assertSame( + '
', + $template->render(['attributes' => $attributes]), + ); + } +} diff --git a/src/Map/tests/Twig/MapExtensionTest.php b/src/Map/tests/Twig/MapExtensionTest.php new file mode 100644 index 00000000000..e78e0ec3d16 --- /dev/null +++ b/src/Map/tests/Twig/MapExtensionTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Twig; + +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Map\Map; +use Symfony\UX\Map\Point; +use Symfony\UX\Map\Renderer\RendererInterface; +use Symfony\UX\Map\Tests\Kernel\TwigAppKernel; +use Symfony\UX\Map\Twig\MapExtension; +use Symfony\UX\Map\Twig\MapRuntime; +use Twig\DeprecatedCallableInfo; +use Twig\Environment; +use Twig\Loader\ArrayLoader; +use Twig\Loader\ChainLoader; + +class MapExtensionTest extends KernelTestCase +{ + use ExpectDeprecationTrait; + + protected static function getKernelClass(): string + { + return TwigAppKernel::class; + } + + public function testExtensionIsRegistered(): void + { + /** @var Environment $twig */ + $twig = self::getContainer()->get('twig'); + + $this->assertTrue($twig->hasExtension(MapExtension::class)); + $this->assertInstanceOf(MapExtension::class, $twig->getExtension(MapExtension::class)); + } + + public function testRuntimeIsRegistered(): void + { + /** @var Environment $twig */ + $twig = self::getContainer()->get('twig'); + + $this->assertInstanceOf(MapRuntime::class, $twig->getRuntime(MapRuntime::class)); + } + + /** + * @group legacy + */ + public function testRenderFunctionIsDeprecated(): void + { + $map = (new Map()) + ->center(new Point(latitude: 5, longitude: 10)) + ->zoom(4); + + $renderer = self::createMock(RendererInterface::class); + $renderer + ->expects(self::once()) + ->method('renderMap') + ->with($map, []) + ->willReturn('') + ; + self::getContainer()->set('test.ux_map.renderers', $renderer); + + /** @var Environment $twig */ + $twig = self::getContainer()->get('twig'); + $twig->setLoader(new ChainLoader([ + new ArrayLoader([ + 'test' => '{{ render_map(map) }}', + ]), + $twig->getLoader(), + ])); + + if (class_exists(DeprecatedCallableInfo::class)) { + $this->expectDeprecation('Since symfony/ux-map 2.20: Twig Function "render_map" is deprecated; use "ux_map" instead in test at line 1.'); + } else { + $this->expectDeprecation('Since symfony/ux-map 2.20: Twig Function "render_map" is deprecated. Use "ux_map" instead in test at line 1.'); + } + $html = $twig->render('test', ['map' => $map]); + $this->assertSame('', $html); + } + + public function testMapFunctionWithArray(): void + { + $map = (new Map()) + ->center(new Point(latitude: 5, longitude: 10)) + ->zoom(4); + $attributes = ['data-foo' => 'bar']; + + $renderer = self::createMock(RendererInterface::class); + $renderer + ->expects(self::once()) + ->method('renderMap') + ->with($map, $attributes) + ->willReturn('
') + ; + self::getContainer()->set('test.ux_map.renderers', $renderer); + + $twig = self::getContainer()->get('twig'); + $template = $twig->createTemplate('{{ ux_map(center: {lat: 5, lng: 10}, zoom: 4, attributes: attributes) }}'); + + $this->assertSame( + '
', + $template->render(['attributes' => $attributes]), + ); + } +}