diff --git a/apps/e2e/.symfony.local.yaml b/apps/e2e/.symfony.local.yaml index 3d0a7009141..8d425e86b43 100644 --- a/apps/e2e/.symfony.local.yaml +++ b/apps/e2e/.symfony.local.yaml @@ -1,2 +1,3 @@ http: port: 9876 + no_tls: true diff --git a/apps/e2e/assets/icons/mdi/search.svg b/apps/e2e/assets/icons/mdi/search.svg new file mode 100644 index 00000000000..c5c75c4d193 --- /dev/null +++ b/apps/e2e/assets/icons/mdi/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/e2e/composer.json b/apps/e2e/composer.json index 4736d725fae..8a868cfc154 100644 --- a/apps/e2e/composer.json +++ b/apps/e2e/composer.json @@ -38,7 +38,10 @@ "symfony/http-client": "6.4.*|7.3.*", "symfony/intl": "6.4.*|7.3.*", "symfony/monolog-bundle": "^3.10", + "symfony/property-access": "6.4.*|7.3.*", + "symfony/property-info": "6.4.*|7.3.*", "symfony/runtime": "6.4.*|7.3.*", + "symfony/serializer": "6.4.*|7.3.*", "symfony/stimulus-bundle": "^2.29.1", "symfony/twig-bundle": "6.4.*|7.3.*", "symfony/ux-autocomplete": "^2.29.1", diff --git a/apps/e2e/src/Controller/AutocompleteController.php b/apps/e2e/src/Controller/AutocompleteController.php index bc530c60543..2e1f21d99c3 100644 --- a/apps/e2e/src/Controller/AutocompleteController.php +++ b/apps/e2e/src/Controller/AutocompleteController.php @@ -8,10 +8,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-autocomplete')] +#[Route('/ux-autocomplete', name: 'app_ux_autocomplete_')] final class AutocompleteController extends AbstractController { - #[Route('/without-ajax')] + #[Route('/without-ajax', name: 'without_ajax')] public function withoutAjax(): Response { $formBuilder = $this->createFormBuilder(); @@ -47,7 +47,7 @@ public function withoutAjax(): Response ]); } - #[Route('/with-ajax')] + #[Route('/with-ajax', name: 'with_ajax')] public function withAjax(): Response { $formBuilder = $this->createFormBuilder(); @@ -60,7 +60,7 @@ public function withAjax(): Response ]); } - #[Route('/custom-controller')] + #[Route('/custom-controller', name: 'custom_controller')] public function customController(): Response { return $this->render('ux_autocomplete/custom_controller.html.twig'); diff --git a/apps/e2e/src/Controller/ChartjsController.php b/apps/e2e/src/Controller/ChartjsController.php index f303b2382c7..5e1814a4f2e 100644 --- a/apps/e2e/src/Controller/ChartjsController.php +++ b/apps/e2e/src/Controller/ChartjsController.php @@ -8,10 +8,10 @@ use Symfony\UX\Chartjs\Builder\ChartBuilderInterface; use Symfony\UX\Chartjs\Model\Chart; -#[Route('/ux-chartjs')] +#[Route('/ux-chartjs', name: 'app_ux_chartjs_')] final class ChartjsController extends AbstractController { - #[Route('/without-options')] + #[Route('/without-options', name: 'without_options')] public function withoutOptions(ChartBuilderInterface $chartBuilder): Response { $chart = $chartBuilder->createChart(Chart::TYPE_LINE); @@ -33,7 +33,7 @@ public function withoutOptions(ChartBuilderInterface $chartBuilder): Response ]); } - #[Route('/with-options')] + #[Route('/with-options', name: 'with_options')] public function withOptions(ChartBuilderInterface $chartBuilder): Response { $chart = $chartBuilder->createChart(Chart::TYPE_LINE); diff --git a/apps/e2e/src/Controller/CropperjsController.php b/apps/e2e/src/Controller/CropperjsController.php index cf9363c4dbc..0d5ea357f2a 100644 --- a/apps/e2e/src/Controller/CropperjsController.php +++ b/apps/e2e/src/Controller/CropperjsController.php @@ -6,10 +6,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-cropperjs')] +#[Route('/ux-cropperjs', name: 'app_ux_cropperjs_')] final class CropperjsController extends AbstractController { - #[Route('/')] + #[Route('/', name: 'index')] public function index(): Response { return $this->render('ux_cropperjs/index.html.twig', [ diff --git a/apps/e2e/src/Controller/DropzoneController.php b/apps/e2e/src/Controller/DropzoneController.php index 1dfe103d6f5..baa4b73bf00 100644 --- a/apps/e2e/src/Controller/DropzoneController.php +++ b/apps/e2e/src/Controller/DropzoneController.php @@ -6,10 +6,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-dropzone')] +#[Route('/ux-dropzone', name: 'app_ux_dropzone_')] final class DropzoneController extends AbstractController { - #[Route('/')] + #[Route('/', name: 'index')] public function index(): Response { return $this->render('ux_dropzone/index.html.twig', [ diff --git a/apps/e2e/src/Controller/HomeController.php b/apps/e2e/src/Controller/HomeController.php index 31e48e7d8a8..76f7402fa88 100644 --- a/apps/e2e/src/Controller/HomeController.php +++ b/apps/e2e/src/Controller/HomeController.php @@ -2,7 +2,6 @@ namespace App\Controller; -use App\Repository\ExampleRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -10,10 +9,8 @@ final class HomeController extends AbstractController { #[Route('/', name: 'app_home')] - public function index(ExampleRepository $exampleRepository): Response + public function index(): Response { - return $this->render('home.html.twig', [ - 'examples_by_package' => $exampleRepository->findAllByPackage(), - ]); + return $this->render('home.html.twig'); } } diff --git a/apps/e2e/src/Controller/IconsController.php b/apps/e2e/src/Controller/IconsController.php index 81b47258867..b0f0d6c89ef 100644 --- a/apps/e2e/src/Controller/IconsController.php +++ b/apps/e2e/src/Controller/IconsController.php @@ -6,10 +6,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-icons')] +#[Route('/ux-icons', name: 'app_ux_icons_')] final class IconsController extends AbstractController { - #[Route('/')] + #[Route('/', name: 'index')] public function index(): Response { return $this->render('ux_icons/index.html.twig', [ diff --git a/apps/e2e/src/Controller/LiveComponentController.php b/apps/e2e/src/Controller/LiveComponentController.php index c348f73d733..b85474a3774 100644 --- a/apps/e2e/src/Controller/LiveComponentController.php +++ b/apps/e2e/src/Controller/LiveComponentController.php @@ -6,14 +6,68 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-live-component')] +#[Route('/ux-live-component', name: 'app_ux_live_component_')] final class LiveComponentController extends AbstractController { - #[Route('/')] - public function index(): Response + #[Route('/counter', name: 'counter')] + public function counter(): Response { - return $this->render('ux_live_component/index.html.twig', [ - 'controller_name' => 'LiveComponentController', + return $this->render('ux_live_component/counter.html.twig'); + } + + #[Route('/registration-form', name: 'registration_form')] + public function registrationForm(): Response + { + return $this->render('ux_live_component/registration_form.html.twig'); + } + + #[Route('/fruits/{page?1}', name: 'fruits')] + public function fruits(int $page): Response + { + return $this->render('ux_live_component/fruits.html.twig', [ + 'page' => $page, ]); } + + #[Route('/with-dto', name: 'with_dto')] + public function withDto(): Response + { + return $this->render('ux_live_component/with_dto.html.twig'); + } + + #[Route('/with-dto-collection', name: 'with_dto_collection')] + public function withDtoCollection(): Response + { + return $this->render('ux_live_component/with_dto_collection.html.twig'); + } + + #[Route('/with-dto-and-serializer', name: 'with_dto_and_serializer')] + public function withDtoAndSerializer(): Response + { + return $this->render('ux_live_component/with_dto_and_serializer.html.twig'); + } + + #[Route('/with-dto-and-custom-hydration-methods', name: 'with_dto_and_custom_hydration_methods')] + public function withDtoAndCustomHydrationMethods(): Response + { + return $this->render('ux_live_component/with_dto_and_custom_hydration_methods.html.twig'); + } + + #[Route('/with-dto-and-hydration-extension', name: 'with_dto_and_hydration_extension')] + public function withDtoAndHydrationExtension(): Response + { + return $this->render('ux_live_component/with_dto_and_hydration_extension.html.twig'); + } + + #[Route('/item-list', name: 'item_list')] + public function itemList(): Response + { + return $this->render('ux_live_component/item_list.html.twig'); + } + + #[Route('/with-aliased-live-props', name: 'with_aliased_live_props')] + public function withAliasedLiveProps(): Response + { + return $this->render('ux_live_component/with_aliased_live_props.html.twig'); + } } diff --git a/apps/e2e/src/Controller/MapController.php b/apps/e2e/src/Controller/MapController.php index b374a859317..2b0c3589b4a 100644 --- a/apps/e2e/src/Controller/MapController.php +++ b/apps/e2e/src/Controller/MapController.php @@ -19,10 +19,10 @@ use Symfony\UX\Map\Polyline; use Symfony\UX\Map\Rectangle; -#[Route('/ux-map')] +#[Route('/ux-map', name: 'app_ux_map_')] final class MapController extends AbstractController { - #[Route('/basic')] + #[Route('/basic', name: 'basic')] public function basic( #[MapQueryParameter] MapRenderer $renderer ): Response { @@ -34,7 +34,7 @@ public function basic( return $this->render('ux_map/render_map.html.twig', ['map' => $map]); } - #[Route('/with-markers-and-fit-bounds-to-markers')] + #[Route('/with-markers-and-fit-bounds-to-markers', name: 'with_markers_and_fit_bounds_to_markers')] public function withMarkersAndFitBoundsToMarkers(#[MapQueryParameter] MapRenderer $renderer): Response { $map = (new Map(rendererName: $renderer->value)) @@ -52,7 +52,7 @@ public function withMarkersAndFitBoundsToMarkers(#[MapQueryParameter] MapRendere return $this->render('ux_map/render_map.html.twig', ['map' => $map]); } - #[Route('/with-markers-and-zoomed-on-paris')] + #[Route('/with-markers-and-zoomed-on-paris', name: 'with_markers_and_zoomed_on_paris')] public function withMarkersZoomedOnParis(#[MapQueryParameter] MapRenderer $renderer): Response { $map = (new Map(rendererName: $renderer->value)) @@ -71,7 +71,7 @@ public function withMarkersZoomedOnParis(#[MapQueryParameter] MapRenderer $rende return $this->render('ux_map/render_map.html.twig', ['map' => $map]); } - #[Route('/with-markers-and-info-windows')] + #[Route('/with-markers-and-info-windows', name: 'with_markers_and_info_windows')] public function withMarkersAndInfoWindows(#[MapQueryParameter] MapRenderer $renderer): Response { $map = (new Map(rendererName: $renderer->value)) @@ -91,7 +91,7 @@ public function withMarkersAndInfoWindows(#[MapQueryParameter] MapRenderer $rend return $this->render('ux_map/render_map.html.twig', ['map' => $map]); } - #[Route('/with-markers-and-custom-icons')] + #[Route('/with-markers-and-custom-icons', name: 'with_markers_and_custom_icons')] public function withMarkersAndCustomIcons( #[MapQueryParameter] MapRenderer $renderer, #[Autowire(service: 'asset_mapper.asset_package')] PackageInterface $package, @@ -119,7 +119,7 @@ public function withMarkersAndCustomIcons( return $this->render('ux_map/render_map.html.twig', ['map' => $map]); } - #[Route('/with-polygons')] + #[Route('/with-polygons', name: 'with_polygons')] public function withPolygons(#[MapQueryParameter] MapRenderer $renderer): Response { $map = (new Map(rendererName: $renderer->value)) @@ -160,7 +160,7 @@ public function withPolygons(#[MapQueryParameter] MapRenderer $renderer): Respon return $this->render('ux_map/render_map.html.twig', ['map' => $map]); } - #[Route('/with-polylines')] + #[Route('/with-polylines', name: 'with_polylines')] public function withPolylines(#[MapQueryParameter] MapRenderer $renderer): Response { $map = (new Map(rendererName: $renderer->value)) @@ -192,7 +192,7 @@ public function withPolylines(#[MapQueryParameter] MapRenderer $renderer): Respo return $this->render('ux_map/render_map.html.twig', ['map' => $map]); } - #[Route('/with-circles')] + #[Route('/with-circles', name: 'with_circles')] public function withCircles(#[MapQueryParameter] MapRenderer $renderer): Response { $map = (new Map(rendererName: $renderer->value)) @@ -217,7 +217,7 @@ public function withCircles(#[MapQueryParameter] MapRenderer $renderer): Respons return $this->render('ux_map/render_map.html.twig', ['map' => $map]); } - #[Route('/with-rectangles')] + #[Route('/with-rectangles', name: 'with_rectangles')] public function withRectangles(#[MapQueryParameter] MapRenderer $renderer): Response { $map = (new Map(rendererName: $renderer->value)) diff --git a/apps/e2e/src/Controller/NotifyController.php b/apps/e2e/src/Controller/NotifyController.php index 9f53da9ade6..a1f81b2d6b2 100644 --- a/apps/e2e/src/Controller/NotifyController.php +++ b/apps/e2e/src/Controller/NotifyController.php @@ -6,10 +6,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-notify')] +#[Route('/ux-notify', name: 'app_ux_notify_')] final class NotifyController extends AbstractController { - #[Route('/')] + #[Route('/', name: 'index')] public function index(): Response { return $this->render('ux_notify/index.html.twig', [ diff --git a/apps/e2e/src/Controller/ReactController.php b/apps/e2e/src/Controller/ReactController.php index 5f45a282737..8ab8b99527c 100644 --- a/apps/e2e/src/Controller/ReactController.php +++ b/apps/e2e/src/Controller/ReactController.php @@ -6,10 +6,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-react')] +#[Route('/ux-react', name: 'app_ux_react_')] final class ReactController extends AbstractController { - #[Route('/')] + #[Route('/', name: 'index')] public function index(): Response { return $this->render('ux_react/index.html.twig'); diff --git a/apps/e2e/src/Controller/SvelteController.php b/apps/e2e/src/Controller/SvelteController.php index b17d5bfacb3..e807cbb0d5b 100644 --- a/apps/e2e/src/Controller/SvelteController.php +++ b/apps/e2e/src/Controller/SvelteController.php @@ -6,10 +6,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-svelte')] +#[Route('/ux-svelte', name: 'app_ux_svelte_')] final class SvelteController extends AbstractController { - #[Route('/')] + #[Route('/', name: 'index')] public function index(): Response { return $this->render('ux_svelte/index.html.twig'); diff --git a/apps/e2e/src/Controller/TestAutocompleteController.php b/apps/e2e/src/Controller/TestAutocompleteController.php index 72168a638e8..9a12f7a6b7c 100644 --- a/apps/e2e/src/Controller/TestAutocompleteController.php +++ b/apps/e2e/src/Controller/TestAutocompleteController.php @@ -8,17 +8,17 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/test')] +#[Route('/test-autocomplete', name: 'app_test_autocomplete_')] final class TestAutocompleteController extends AbstractController { - #[Route('/autocomplete-dynamic-form', name: 'test_autocomplete_dynamic_form')] + #[Route('/dynamic-form', name: 'dynamic_form')] public function dynamicForm(): Response { return $this->render('test/autocomplete_dynamic_form.html.twig'); } - #[Route('/autocomplete/movie', name: 'test_autocomplete_movie')] - public function movieAutocomplete(Request $request): JsonResponse + #[Route('/movie', name: 'movie')] + public function movie(Request $request): JsonResponse { $query = $request->query->get('query', ''); @@ -39,8 +39,8 @@ public function movieAutocomplete(Request $request): JsonResponse ]); } - #[Route('/autocomplete/videogame', name: 'test_autocomplete_videogame')] - public function videogameAutocomplete(Request $request): JsonResponse + #[Route('/videogame', name: 'videogame')] + public function videogame(Request $request): JsonResponse { $query = $request->query->get('query', ''); diff --git a/apps/e2e/src/Controller/TranslatorController.php b/apps/e2e/src/Controller/TranslatorController.php index 0d3ecf2611c..f09184fbd24 100644 --- a/apps/e2e/src/Controller/TranslatorController.php +++ b/apps/e2e/src/Controller/TranslatorController.php @@ -6,52 +6,52 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-translator')] +#[Route('/ux-translator', name: 'app_ux_translator_')] final class TranslatorController extends AbstractController { - #[Route('/basic')] + #[Route('/basic', name: 'basic')] public function basic(): Response { return $this->render('ux_translator/basic.html.twig'); } - #[Route('/with-parameter')] + #[Route('/with-parameter', name: 'with_parameter')] public function withParameter(): Response { return $this->render('ux_translator/with_parameter.html.twig'); } - #[Route('/icu-select')] + #[Route('/icu-select', name: 'icu_select')] public function icuSelect(): Response { return $this->render('ux_translator/icu_select.html.twig'); } - #[Route('/icu-plural')] + #[Route('/icu-plural', name: 'icu_plural')] public function icuPlural(): Response { return $this->render('ux_translator/icu_plural.html.twig'); } - #[Route('/icu-selectordinal')] + #[Route('/icu-selectordinal', name: 'icu_selectordinal')] public function icuSelectOrdinal(): Response { return $this->render('ux_translator/icu_selectordinal.html.twig'); } - #[Route('/icu-date-time')] + #[Route('/icu-date-time', name: 'icu_date_time')] public function icuDateTime(): Response { return $this->render('ux_translator/icu_date_time.html.twig'); } - #[Route('/icu-number-percent')] + #[Route('/icu-number-percent', name: 'icu_number_percent')] public function icuNumberPercent(): Response { return $this->render('ux_translator/icu_number_percent.html.twig'); } - #[Route('/icu-number-currency')] + #[Route('/icu-number-currency', name: 'icu_number_currency')] public function icuNumberCurrency(): Response { return $this->render('ux_translator/icu_number_currency.html.twig'); diff --git a/apps/e2e/src/Controller/TurboController.php b/apps/e2e/src/Controller/TurboController.php index 52646713c9b..ea845b253f4 100644 --- a/apps/e2e/src/Controller/TurboController.php +++ b/apps/e2e/src/Controller/TurboController.php @@ -6,10 +6,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-turbo')] +#[Route('/ux-turbo', name: 'app_ux_turbo_')] final class TurboController extends AbstractController { - #[Route('/')] + #[Route('/', name: 'index')] public function index(): Response { return $this->render('ux_turbo/index.html.twig', [ diff --git a/apps/e2e/src/Controller/TwigComponentController.php b/apps/e2e/src/Controller/TwigComponentController.php index ffd489a0ae7..05f0e103204 100644 --- a/apps/e2e/src/Controller/TwigComponentController.php +++ b/apps/e2e/src/Controller/TwigComponentController.php @@ -6,10 +6,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-twig-component')] +#[Route('/ux-twig-component', name: 'app_ux_twig_component_')] final class TwigComponentController extends AbstractController { - #[Route('/')] + #[Route('/', name: 'index')] public function index(): Response { return $this->render('ux_twig_component/index.html.twig', [ diff --git a/apps/e2e/src/Controller/TypedController.php b/apps/e2e/src/Controller/TypedController.php index 1d9c76906ec..00999ab3604 100644 --- a/apps/e2e/src/Controller/TypedController.php +++ b/apps/e2e/src/Controller/TypedController.php @@ -6,10 +6,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-typed')] +#[Route('/ux-typed', name: 'app_ux_typed_')] final class TypedController extends AbstractController { - #[Route('/')] + #[Route('/', name: 'index')] public function index(): Response { return $this->render('ux_typed/index.html.twig', [ diff --git a/apps/e2e/src/Controller/VueController.php b/apps/e2e/src/Controller/VueController.php index 938858ea9c0..3988b4f66a7 100644 --- a/apps/e2e/src/Controller/VueController.php +++ b/apps/e2e/src/Controller/VueController.php @@ -6,10 +6,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/ux-vue')] +#[Route('/ux-vue', name: 'app_ux_vue_')] final class VueController extends AbstractController { - #[Route('/')] + #[Route('/', name: 'index')] public function index(): Response { return $this->render('ux_vue/index.html.twig'); diff --git a/apps/e2e/src/EventListener/ResolveExampleForUrlListener.php b/apps/e2e/src/EventListener/ResolveExampleForUrlListener.php index 1b056a18c0d..79f203e19bd 100644 --- a/apps/e2e/src/EventListener/ResolveExampleForUrlListener.php +++ b/apps/e2e/src/EventListener/ResolveExampleForUrlListener.php @@ -30,7 +30,7 @@ public function __invoke(RequestEvent $event): void return; } - $example = $this->exampleRepository->findOneByUrl($event->getRequest()->getRequestUri()); + $example = $this->exampleRepository->findOneByRoute($event->getRequest()->attributes->get('_route')); $event->getRequest()->attributes->set('_example', $example); } } diff --git a/apps/e2e/src/Example.php b/apps/e2e/src/Example.php index 9ecade603f4..4adab0b2597 100644 --- a/apps/e2e/src/Example.php +++ b/apps/e2e/src/Example.php @@ -17,7 +17,8 @@ public function __construct( public UxPackage $uxPackage, public string $name, public string $description, - public string $url + public string $routeName, + public array $routeParameters = [], ) { } } diff --git a/apps/e2e/src/Form/Type/MovieAutocompleteType.php b/apps/e2e/src/Form/Type/MovieAutocompleteType.php index e3326948fde..b487bd79495 100644 --- a/apps/e2e/src/Form/Type/MovieAutocompleteType.php +++ b/apps/e2e/src/Form/Type/MovieAutocompleteType.php @@ -18,7 +18,7 @@ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'autocomplete' => true, - 'autocomplete_url' => $this->urlGenerator->generate('test_autocomplete_movie'), + 'autocomplete_url' => $this->urlGenerator->generate('app_test_autocomplete_movie'), 'tom_select_options' => [ 'maxOptions' => null, ], diff --git a/apps/e2e/src/Form/Type/VideogameAutocompleteType.php b/apps/e2e/src/Form/Type/VideogameAutocompleteType.php index 3d7a2cb445d..dcd7abb2f4b 100644 --- a/apps/e2e/src/Form/Type/VideogameAutocompleteType.php +++ b/apps/e2e/src/Form/Type/VideogameAutocompleteType.php @@ -18,7 +18,7 @@ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'autocomplete' => true, - 'autocomplete_url' => $this->urlGenerator->generate('test_autocomplete_videogame'), + 'autocomplete_url' => $this->urlGenerator->generate('app_test_autocomplete_videogame'), 'tom_select_options' => [ 'maxOptions' => null, ], diff --git a/apps/e2e/src/Hydration/PointHydrationExtension.php b/apps/e2e/src/Hydration/PointHydrationExtension.php new file mode 100644 index 00000000000..cd9c7af561f --- /dev/null +++ b/apps/e2e/src/Hydration/PointHydrationExtension.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Hydration; + +use App\Model\Point; +use Symfony\UX\LiveComponent\Hydration\HydrationExtensionInterface; + +/** + * @template TData of Point + * @template TDehydrated of array{point-x: float; point-y: float} + */ +class PointHydrationExtension implements HydrationExtensionInterface +{ + public function supports(string $className): bool + { + return is_a($className, Point::class, true); + } + + /** + * @param TDehydrated $value + * @return null|TData + */ + public function hydrate(mixed $value, string $className): ?object + { + return Point::create($value['px'], $value['py']); + } + + /** + * @param TData $object + * @return TDehydrated + */ + public function dehydrate(object $object): mixed + { + return [ + 'px' => $object->x, + 'py' => $object->y, + ]; + } +} diff --git a/apps/e2e/src/Model/Address.php b/apps/e2e/src/Model/Address.php new file mode 100644 index 00000000000..58e600c05e1 --- /dev/null +++ b/apps/e2e/src/Model/Address.php @@ -0,0 +1,18 @@ +country = $country; + $address->city = $city; + + return $address; + } +} diff --git a/apps/e2e/src/Model/Point.php b/apps/e2e/src/Model/Point.php new file mode 100644 index 00000000000..cad0cec6bce --- /dev/null +++ b/apps/e2e/src/Model/Point.php @@ -0,0 +1,18 @@ +x = $x; + $point->y = $y; + + return $point; + } +} diff --git a/apps/e2e/src/Normalizer/AddressNormalizer.php b/apps/e2e/src/Normalizer/AddressNormalizer.php new file mode 100644 index 00000000000..b84328741da --- /dev/null +++ b/apps/e2e/src/Normalizer/AddressNormalizer.php @@ -0,0 +1,45 @@ + $data->country, + 'serialized_city' => $data->city, + ]; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return $type === Address::class; + } + + public function denormalize($data, string $type, ?string $format = null, array $context = []): object + { + return Address::create( + country: $data['serialized_country'], + city: $data['serialized_city'], + ); + } + + public function getSupportedTypes(?string $format): array + { + return [Address::class => true]; + } +} \ No newline at end of file diff --git a/apps/e2e/src/Repository/ExampleRepository.php b/apps/e2e/src/Repository/ExampleRepository.php index 86e8b78b673..04b0d1cf0e2 100644 --- a/apps/e2e/src/Repository/ExampleRepository.php +++ b/apps/e2e/src/Repository/ExampleRepository.php @@ -24,38 +24,48 @@ class ExampleRepository public function __construct() { $this->examples = [ - new Example(UxPackage::Autocomplete, 'Autocomplete (with AJAX)', 'An autocomplete form field, by fetching results with AJAX.', '/ux-autocomplete/with-ajax'), - new Example(UxPackage::Autocomplete, 'Autocomplete (without AJAX)', 'An autocomplete form field, by using the choses from the choice type field.', '/ux-autocomplete/without-ajax'), - new Example(UxPackage::Autocomplete, 'Autocomplete (custom controller)', 'An autocomplete form field, with a custom Stimulus controller for AJAX results.', '/ux-autocomplete/custom-controller'), - new Example(UxPackage::Map, 'Basic map (Leaflet)', 'A basic map centered on Paris with zoom level 12', '/ux-map/basic?renderer=leaflet'), - new Example(UxPackage::Map, 'Basic map (Google)', 'A basic map centered on Paris with zoom level 12', '/ux-map/basic?renderer=google'), - new Example(UxPackage::Map, 'With markers, fit bounds (Leaflet)', 'A map with 2 markers, and the bounds are automatically adjusted to fit both markers', '/ux-map/with-markers-and-fit-bounds-to-markers?renderer=leaflet'), - new Example(UxPackage::Map, 'With markers, fit bounds (Google)', 'A map with 2 markers, and the bounds are automatically adjusted to fit both markers', '/ux-map/with-markers-and-fit-bounds-to-markers?renderer=google'), - new Example(UxPackage::Map, 'With markers, zoomed on Paris (Leaflet)', 'A map with 2 markers (Paris and Lyon), zoomed on Paris', '/ux-map/with-markers-and-zoomed-on-paris?renderer=leaflet'), - new Example(UxPackage::Map, 'With markers, zoomed on Paris (Google)', 'A map with 2 markers (Paris and Lyon), zoomed on Paris', '/ux-map/with-markers-and-zoomed-on-paris?renderer=google'), - new Example(UxPackage::Map, 'With markers and info windows (Leaflet)', 'A map with 2 markers (Paris and Lyon), each with an info window', '/ux-map/with-markers-and-info-windows?renderer=leaflet'), - new Example(UxPackage::Map, 'With markers and info windows (Google)', 'A map with 2 markers (Paris and Lyon), each with an info window', '/ux-map/with-markers-and-info-windows?renderer=google'), - new Example(UxPackage::Map, 'With custom icon markers (Leaflet)', 'A map with 3 markers (Paris, Lyon, Bordeaux), each with a custom icon', '/ux-map/with-markers-and-custom-icons?renderer=leaflet'), - new Example(UxPackage::Map, 'With custom icon markers (Google)', 'A map with 3 markers (Paris, Lyon, Bordeaux), each with a custom icon', '/ux-map/with-markers-and-custom-icons?renderer=google'), - new Example(UxPackage::Map, 'With polygons (Leaflet)', 'A map with two polygons, one that covers main cities in Italy, and one weird shape on France', '/ux-map/with-polygons?renderer=leaflet'), - new Example(UxPackage::Map, 'With polygons (Google)', 'A map with two polygons, one that covers main cities in Italy, and one weird shape on France', '/ux-map/with-polygons?renderer=google'), - new Example(UxPackage::Map, 'With polylines (Leaflet)', 'A map with two polylines: one through Paris/Lyon/Marseille/Bordeaux, and the other one through Rennes/Nantes/Tours', '/ux-map/with-polylines?renderer=leaflet'), - new Example(UxPackage::Map, 'With polylines (Google)', 'A map with two polylines: one through Paris/Lyon/Marseille/Bordeaux, and the other one through Rennes/Nantes/Tours', '/ux-map/with-polylines?renderer=google'), - new Example(UxPackage::Map, 'With circles (Leaflet)', 'A map with two circles: one centered on Paris, the other on Lyon', '/ux-map/with-circles?renderer=leaflet'), - new Example(UxPackage::Map, 'With circles (Google)', 'A map with two circles: one centered on Paris, the other on Lyon', '/ux-map/with-circles?renderer=google'), - new Example(UxPackage::Map, 'With rectangles (Leaflet)', 'A map with two rectangles: one from Paris to Lille, the other from Lyon to Bordeaux', '/ux-map/with-rectangles?renderer=leaflet'), - new Example(UxPackage::Map, 'With rectangles (Google)', 'A map with two rectangles: one from Paris to Lille, the other from Lyon to Bordeaux', '/ux-map/with-rectangles?renderer=google'), - new Example(UxPackage::React, 'Basic React Component', 'A basic React component that displays a welcoming message', '/ux-react/'), - new Example(UxPackage::Svelte, 'Basic Svelte Component', 'A basic Svelte component that displays a welcoming message', '/ux-svelte/'), - new Example(UxPackage::Translator, 'Basic translation', 'A simple translation example using the Translator component', '/ux-translator/basic'), - new Example(UxPackage::Translator, 'Translation with parameter', 'Translation example with dynamic parameters', '/ux-translator/with-parameter'), - new Example(UxPackage::Translator, 'ICU Translation with `select` argument', 'ICU message format example using the `select` argument', '/ux-translator/icu-select'), - new Example(UxPackage::Translator, 'ICU Translation with `plural` argument', 'ICU message format example using the `plural` argument', '/ux-translator/icu-plural'), - new Example(UxPackage::Translator, 'ICU Translation with `selectordinal` argument', 'ICU message format example using the `selectordinal` argument', '/ux-translator/icu-selectordinal'), - new Example(UxPackage::Translator, 'ICU Translation with `date` and `time` arguments', 'ICU message format example using `date` and `time` arguments', '/ux-translator/icu-date-time'), - new Example(UxPackage::Translator, 'ICU Translation with `number` and `percent` arguments', 'ICU message format example using `number` and `percent` arguments', '/ux-translator/icu-number-percent'), - new Example(UxPackage::Translator, 'ICU Translation with `number` and `currency` arguments', 'ICU message format example using `number` and `currency` arguments', '/ux-translator/icu-number-currency'), - new Example(UxPackage::Vue, 'Basic Vue Component', 'A basic Vue component that displays a welcoming message', '/ux-vue/'), + new Example(UxPackage::Autocomplete, 'Autocomplete (without AJAX)', 'An autocomplete form field, by using the choses from the choice type field.', 'app_ux_autocomplete_without_ajax'), + new Example(UxPackage::Autocomplete, 'Autocomplete (custom controller)', 'An autocomplete form field, with a custom Stimulus controller for AJAX results.', 'app_ux_autocomplete_custom_controller'), + new Example(UxPackage::LiveComponent, 'Examples filtering', "On this page, you can filter all examples by query terms, and observe how the UI and URLs update during and after processing.", 'app_home'), + new Example(UxPackage::LiveComponent, 'Counter', 'A basic counter that you can increment or decrement.', 'app_ux_live_component_counter'), + new Example(UxPackage::LiveComponent, 'Registration form', 'A registration form with live validation using Symfony Forms and the Validator component.', 'app_ux_live_component_registration_form'), + new Example(UxPackage::LiveComponent, 'Paginated fruits list', 'A paginated list of fruits, where the current page is persisted in the URL as a path parameter.', 'app_ux_live_component_fruits'), + new Example(UxPackage::LiveComponent, 'With DTO', 'A live component that uses a DTO to encapsulate its state.', 'app_ux_live_component_with_dto'), + new Example(UxPackage::LiveComponent, 'With DTO Collection', 'A live component that uses a collection of Data Transfer Objects (DTOs) to encapsulate its state.', 'app_ux_live_component_with_dto_collection'), + new Example(UxPackage::LiveComponent, 'With DTO and Serializer', 'A live component that uses a DTO along with the Symfony Serializer component.', 'app_ux_live_component_with_dto_and_serializer'), + new Example(UxPackage::LiveComponent, 'With DTO and custom Hydration/Dehydration methods', 'A live component that uses a DTO along with custom methods to hydrate/dehydrate the DTO.', 'app_ux_live_component_with_dto_and_custom_hydration_methods'), + new Example(UxPackage::LiveComponent, 'With DTO and dedicated HydrationExtension', 'A live component that uses a DTO along with dedicated HydrationExtension to hydrate/dehydrate the DTO.', 'app_ux_live_component_with_dto_and_hydration_extension'), + new Example(UxPackage::LiveComponent, 'Item list', 'A live component with LiveProp, LiveAction and LiveArg.', 'app_ux_live_component_item_list'), + new Example(UxPackage::LiveComponent, 'With aliased LiveProps', 'A live component with LiveProps statically and dynamically aliased.', 'app_ux_live_component_with_aliased_live_props'), + new Example(UxPackage::Map, 'Basic map (Leaflet)', 'A basic map centered on Paris with zoom level 12', 'app_ux_map_basic', ['renderer' => 'leaflet']), + new Example(UxPackage::Map, 'Basic map (Google)', 'A basic map centered on Paris with zoom level 12', 'app_ux_map_basic', ['renderer' => 'google']), + new Example(UxPackage::Map, 'With markers, fit bounds (Leaflet)', 'A map with 2 markers, and the bounds are automatically adjusted to fit both markers', 'app_ux_map_with_markers_and_fit_bounds_to_markers', ['renderer' => 'leaflet']), + new Example(UxPackage::Map, 'With markers, fit bounds (Google)', 'A map with 2 markers, and the bounds are automatically adjusted to fit both markers', 'app_ux_map_with_markers_and_fit_bounds_to_markers', ['renderer' => 'google']), + new Example(UxPackage::Map, 'With markers, zoomed on Paris (Leaflet)', 'A map with 2 markers (Paris and Lyon), zoomed on Paris', 'app_ux_map_with_markers_and_zoomed_on_paris', ['renderer' => 'leaflet']), + new Example(UxPackage::Map, 'With markers, zoomed on Paris (Google)', 'A map with 2 markers (Paris and Lyon), zoomed on Paris', 'app_ux_map_with_markers_and_zoomed_on_paris', ['renderer' => 'google']), + new Example(UxPackage::Map, 'With markers and info windows (Leaflet)', 'A map with 2 markers (Paris and Lyon), each with an info window', 'app_ux_map_with_markers_and_info_windows', ['renderer' => 'leaflet']), + new Example(UxPackage::Map, 'With markers and info windows (Google)', 'A map with 2 markers (Paris and Lyon), each with an info window', 'app_ux_map_with_markers_and_info_windows', ['renderer' => 'google']), + new Example(UxPackage::Map, 'With custom icon markers (Leaflet)', 'A map with 3 markers (Paris, Lyon, Bordeaux), each with a custom icon', 'app_ux_map_with_markers_and_custom_icons', ['renderer' => 'leaflet']), + new Example(UxPackage::Map, 'With custom icon markers (Google)', 'A map with 3 markers (Paris, Lyon, Bordeaux), each with a custom icon', 'app_ux_map_with_markers_and_custom_icons', ['renderer' => 'google']), + new Example(UxPackage::Map, 'With polygons (Leaflet)', 'A map with two polygons, one that covers main cities in Italy, and one weird shape on France', 'app_ux_map_with_polygons', ['renderer' => 'leaflet']), + new Example(UxPackage::Map, 'With polygons (Google)', 'A map with two polygons, one that covers main cities in Italy, and one weird shape on France', 'app_ux_map_with_polygons', ['renderer' => 'google']), + new Example(UxPackage::Map, 'With polylines (Leaflet)', 'A map with two polylines: one through Paris/Lyon/Marseille/Bordeaux, and the other one through Rennes/Nantes/Tours', 'app_ux_map_with_polylines', ['renderer' => 'leaflet']), + new Example(UxPackage::Map, 'With polylines (Google)', 'A map with two polylines: one through Paris/Lyon/Marseille/Bordeaux, and the other one through Rennes/Nantes/Tours', 'app_ux_map_with_polylines', ['renderer' => 'google']), + new Example(UxPackage::Map, 'With circles (Leaflet)', 'A map with two circles: one centered on Paris, the other on Lyon', 'app_ux_map_with_circles', ['renderer' => 'leaflet']), + new Example(UxPackage::Map, 'With circles (Google)', 'A map with two circles: one centered on Paris, the other on Lyon', 'app_ux_map_with_circles', ['renderer' => 'google']), + new Example(UxPackage::Map, 'With rectangles (Leaflet)', 'A map with two rectangles: one from Paris to Lille, the other from Lyon to Bordeaux', 'app_ux_map_with_rectangles', ['renderer' => 'leaflet']), + new Example(UxPackage::Map, 'With rectangles (Google)', 'A map with two rectangles: one from Paris to Lille, the other from Lyon to Bordeaux', 'app_ux_map_with_rectangles', ['renderer' => 'google']), + new Example(UxPackage::React, 'Basic React Component', 'A basic React component that displays a welcoming message', 'app_ux_react_index'), + new Example(UxPackage::Svelte, 'Basic Svelte Component', 'A basic Svelte component that displays a welcoming message', 'app_ux_svelte_index'), + new Example(UxPackage::Translator, 'Basic translation', 'A simple translation example using the Translator component', 'app_ux_translator_basic'), + new Example(UxPackage::Translator, 'Translation with parameter', 'Translation example with dynamic parameters', 'app_ux_translator_with_parameter'), + new Example(UxPackage::Translator, 'ICU Translation with `select` argument', 'ICU message format example using the `select` argument', 'app_ux_translator_icu_select'), + new Example(UxPackage::Translator, 'ICU Translation with `plural` argument', 'ICU message format example using the `plural` argument', 'app_ux_translator_icu_plural'), + new Example(UxPackage::Translator, 'ICU Translation with `selectordinal` argument', 'ICU message format example using the `selectordinal` argument', 'app_ux_translator_icu_selectordinal'), + new Example(UxPackage::Translator, 'ICU Translation with `date` and `time` arguments', 'ICU message format example using `date` and `time` arguments', 'app_ux_translator_icu_date_time'), + new Example(UxPackage::Translator, 'ICU Translation with `number` and `percent` arguments', 'ICU message format example using `number` and `percent` arguments', 'app_ux_translator_icu_number_percent'), + new Example(UxPackage::Translator, 'ICU Translation with `number` and `currency` arguments', 'ICU message format example using `number` and `currency` arguments', 'app_ux_translator_icu_number_currency'), + new Example(UxPackage::Vue, 'Basic Vue Component', 'A basic Vue component that displays a welcoming message', 'app_ux_vue_index'), ]; } @@ -67,21 +77,32 @@ public function findAll(): array return $this->examples; } - public function findAllByPackage(): array + /** + * @return array> + */ + public function findAllGroupedByPackage(string|null $query = null): array { $grouped = []; + $examples = $this->examples; - foreach ($this->examples as $example) { + if (null !== $query) { + $query = strtolower($query); + $examples = array_filter($examples, + fn(Example $example) => false !== mb_stripos($example->uxPackage->name . ' ' . $example->name . ' ' . $example->description, $query) + ); + } + + foreach ($examples as $example) { $grouped[$example->uxPackage->value][] = $example; } return $grouped; } - public function findOneByUrl(string $url): ?Example + public function findOneByRoute(string $routeName): ?Example { foreach ($this->examples as $example) { - if ($example->url === $url) { + if ($example->routeName === $routeName) { return $example; } } diff --git a/apps/e2e/src/Repository/FruitRepository.php b/apps/e2e/src/Repository/FruitRepository.php index 0118efe9de4..6d7bf4f12f8 100644 --- a/apps/e2e/src/Repository/FruitRepository.php +++ b/apps/e2e/src/Repository/FruitRepository.php @@ -15,4 +15,18 @@ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Fruit::class); } + + /** + * @param positive-int $page + * @param positive-int $perPage + * @return Fruit[] + */ + public function paginate(int $page, int $perPage): array + { + return $this->createQueryBuilder('f') + ->setFirstResult(($page - 1) * $perPage) + ->setMaxResults($perPage) + ->getQuery() + ->getResult(); + } } diff --git a/apps/e2e/src/Twig/Components/LiveComponentWithAliasedLiveProps.php b/apps/e2e/src/Twig/Components/LiveComponentWithAliasedLiveProps.php new file mode 100644 index 00000000000..5e5d62b302d --- /dev/null +++ b/apps/e2e/src/Twig/Components/LiveComponentWithAliasedLiveProps.php @@ -0,0 +1,25 @@ +withUrl(new UrlMapping(as: 'cat')); + } +} diff --git a/apps/e2e/src/Twig/Components/LiveComponentWithDto.php b/apps/e2e/src/Twig/Components/LiveComponentWithDto.php new file mode 100644 index 00000000000..7c187c9c91d --- /dev/null +++ b/apps/e2e/src/Twig/Components/LiveComponentWithDto.php @@ -0,0 +1,29 @@ +address = Address::create( + country: 'France', + city: 'Lyon', + ); + + return $data; + } +} diff --git a/apps/e2e/src/Twig/Components/LiveComponentWithDtoAndCustomHydrationMethods.php b/apps/e2e/src/Twig/Components/LiveComponentWithDtoAndCustomHydrationMethods.php new file mode 100644 index 00000000000..9d5fee1237e --- /dev/null +++ b/apps/e2e/src/Twig/Components/LiveComponentWithDtoAndCustomHydrationMethods.php @@ -0,0 +1,51 @@ +address = Address::create( + country: 'France', + city: 'Lyon', + ); + } + + public function dehydrateAddress(Address|null $address): array|null + { + if (null === $address) { + return null; + } + + return [ + 'x-country' => $address->country, + 'x-city' => $address->city + ]; + } + + public function hydrateAddress(array|null $data): Address + { + $address = new Address(); + + if (null !== $data) { + $address->country = $data['x-country']; + $address->city = $data['x-city']; + } + + return $address; + } +} diff --git a/apps/e2e/src/Twig/Components/LiveComponentWithDtoAndHydrationExtension.php b/apps/e2e/src/Twig/Components/LiveComponentWithDtoAndHydrationExtension.php new file mode 100644 index 00000000000..fb833886c75 --- /dev/null +++ b/apps/e2e/src/Twig/Components/LiveComponentWithDtoAndHydrationExtension.php @@ -0,0 +1,27 @@ +point = Point::create( + x: 69.420, + y: -1.337, + ); + } +} diff --git a/apps/e2e/src/Twig/Components/LiveComponentWithDtoAndSerializer.php b/apps/e2e/src/Twig/Components/LiveComponentWithDtoAndSerializer.php new file mode 100644 index 00000000000..9fa5a78bf2a --- /dev/null +++ b/apps/e2e/src/Twig/Components/LiveComponentWithDtoAndSerializer.php @@ -0,0 +1,27 @@ +address = Address::create( + country: 'France', + city: 'Lyon', + ); + } +} diff --git a/apps/e2e/src/Twig/Components/LiveComponentWithDtoCollection.php b/apps/e2e/src/Twig/Components/LiveComponentWithDtoCollection.php new file mode 100644 index 00000000000..0f473f8468a --- /dev/null +++ b/apps/e2e/src/Twig/Components/LiveComponentWithDtoCollection.php @@ -0,0 +1,53 @@ +canAddAddress()) { + return; + } + + match(count($this->addresses)) { + 0 => $this->addresses[] = Address::create( + country: 'France', + city: 'Lyon', + ), + 1 => $this->addresses[] = Address::create( + country: 'South Korea', + city: 'Seoul', + ), + default => null, + }; + } + + #[LiveAction] + public function reset(): void + { + $this->addresses = []; + } + + public function canAddAddress(): bool + { + return count($this->addresses) < 2; + } +} diff --git a/apps/e2e/src/Twig/Components/LiveCounter.php b/apps/e2e/src/Twig/Components/LiveCounter.php new file mode 100644 index 00000000000..241ab6d43c5 --- /dev/null +++ b/apps/e2e/src/Twig/Components/LiveCounter.php @@ -0,0 +1,29 @@ +value -= 1; + } + + #[LiveAction] + public function increment(): void + { + $this->value += 1; + } +} diff --git a/apps/e2e/src/Twig/Components/LiveExamplesSearch.php b/apps/e2e/src/Twig/Components/LiveExamplesSearch.php new file mode 100644 index 00000000000..ebbbb295f71 --- /dev/null +++ b/apps/e2e/src/Twig/Components/LiveExamplesSearch.php @@ -0,0 +1,34 @@ +exampleRepository->findAllGroupedByPackage($this->query); + } + + #[LiveAction] + public function clearQuery(): void + { + $this->query = ''; + } +} diff --git a/apps/e2e/src/Twig/Components/LiveFruitsPagination.php b/apps/e2e/src/Twig/Components/LiveFruitsPagination.php new file mode 100644 index 00000000000..8a850290612 --- /dev/null +++ b/apps/e2e/src/Twig/Components/LiveFruitsPagination.php @@ -0,0 +1,58 @@ +page < 1 ? 1 : $this->page; + + return $this->fruitRepository->paginate(page: $page, perPage: 5); + } + + public function hasPreviousPage(): bool + { + return $this->page > 1; + } + + public function hasNextPage(): bool + { + // not very efficient, but good enough for this example + return \count($this->fruitRepository->paginate(page: $this->page + 1, perPage: 8)) > 0; + } + + #[LiveAction] + public function goToPreviousPage(): void + { + if ($this->hasPreviousPage()) { + $this->page--; + } + } + + #[LiveAction] + public function goToNextPage(): void + { + if ($this->hasNextPage()) { + $this->page++; + } + } +} diff --git a/apps/e2e/src/Twig/Components/LiveItemList.php b/apps/e2e/src/Twig/Components/LiveItemList.php new file mode 100644 index 00000000000..bdc1799f297 --- /dev/null +++ b/apps/e2e/src/Twig/Components/LiveItemList.php @@ -0,0 +1,41 @@ +items[] = ''; + } + + #[LiveAction] + public function deleteItems(): void + { + $this->items = []; + } + + #[LiveAction] + public function deleteItem(#[LiveArg] int $key): void + { + unset($this->items[$key]); + } +} diff --git a/apps/e2e/src/Twig/Components/LiveRegistrationForm.php b/apps/e2e/src/Twig/Components/LiveRegistrationForm.php new file mode 100644 index 00000000000..c4c88ecf653 --- /dev/null +++ b/apps/e2e/src/Twig/Components/LiveRegistrationForm.php @@ -0,0 +1,61 @@ +getForm()->isSubmitted() && !$this->getForm()->isValid(); + } + + #[LiveAction] + public function saveRegistration(): void + { + $this->submitForm(); + $this->isSuccessful = true; + } + + protected function instantiateForm(): FormInterface + { + return $this->formFactory->createBuilder() + ->add('email', EmailType::class, [ + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Email(), + ], + ]) + ->add('password', PasswordType::class, [ + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Length(['min' => 8]), + ], + // prevent password from being emptied on re-render + 'always_empty' => false, + ]) + ->getForm(); + } +} diff --git a/apps/e2e/src/Twig/Extension/AppExtension.php b/apps/e2e/src/Twig/Extension/AppExtension.php new file mode 100644 index 00000000000..82437bb1d01 --- /dev/null +++ b/apps/e2e/src/Twig/Extension/AppExtension.php @@ -0,0 +1,16 @@ +'.print_r($value, true).''; + }, ['is_safe' => ['html']]); + } +} \ No newline at end of file diff --git a/apps/e2e/src/UxPackage.php b/apps/e2e/src/UxPackage.php index 8e53867765e..14325d19856 100644 --- a/apps/e2e/src/UxPackage.php +++ b/apps/e2e/src/UxPackage.php @@ -17,6 +17,7 @@ enum UxPackage: string case ChartJs = 'UX Chart'; case Cropperjs = 'UX Cropperjs'; case Icons = 'UX Icons'; + case LiveComponent = 'UX LiveComponent'; //case LazyImage = 'UX LazyImage'; // deprecated/removed case Map = 'UX Map'; case Notify = 'UX Notify'; @@ -28,7 +29,6 @@ enum UxPackage: string // case Toolkit; // not subject to E2E case Translator = 'UX Translator'; case Turbo = 'UX Turbo'; - case TwigComponent = 'UX TwigComponent'; // case Typed; // deprecated case Vue = 'UX Vue'; @@ -39,6 +39,7 @@ public function getDocumentationUrl(): string self::ChartJs => 'https://ux.symfony.com/chartjs', self::Cropperjs => 'https://ux.symfony.com/cropperjs', self::Icons => 'https://ux.symfony.com/icons', + self::LiveComponent => 'https://ux.symfony.com/live-component', self::Map => 'https://ux.symfony.com/map', self::Notify => 'https://ux.symfony.com/notify', self::React => 'https://ux.symfony.com/react', @@ -46,7 +47,6 @@ public function getDocumentationUrl(): string self::Svelte => 'https://ux.symfony.com/svelte', self::Translator => 'https://ux.symfony.com/translator', self::Turbo => 'https://ux.symfony.com/turbo', - self::TwigComponent => 'https://ux.symfony.com/twig-component', self::Vue => 'https://ux.symfony.com/vue', }; } diff --git a/apps/e2e/templates/base.html.twig b/apps/e2e/templates/base.html.twig index 001a22a5580..ba9bef57d0c 100644 --- a/apps/e2e/templates/base.html.twig +++ b/apps/e2e/templates/base.html.twig @@ -1,5 +1,5 @@ - + {% block title %}Symfony UX's E2E App{% endblock %} @@ -8,6 +8,10 @@ {% endblock %} {% block javascripts %} + {% block importmap %}{{ importmap('app') }}{% endblock %} {% endblock %} diff --git a/apps/e2e/templates/components/LiveComponentWithAliasedLiveProps.html.twig b/apps/e2e/templates/components/LiveComponentWithAliasedLiveProps.html.twig new file mode 100644 index 00000000000..f76b32d56b8 --- /dev/null +++ b/apps/e2e/templates/components/LiveComponentWithAliasedLiveProps.html.twig @@ -0,0 +1,12 @@ + +
+
+ + +
+
+ + +
+
+ diff --git a/apps/e2e/templates/components/LiveComponentWithDto.html.twig b/apps/e2e/templates/components/LiveComponentWithDto.html.twig new file mode 100644 index 00000000000..466f14c70b9 --- /dev/null +++ b/apps/e2e/templates/components/LiveComponentWithDto.html.twig @@ -0,0 +1,17 @@ + +
+
+ + +
+
+ + +
+
+ +
+ +

Address (print_r)

+ {{ print_r(address) }} + diff --git a/apps/e2e/templates/components/LiveComponentWithDtoAndCustomHydrationMethods.html.twig b/apps/e2e/templates/components/LiveComponentWithDtoAndCustomHydrationMethods.html.twig new file mode 100644 index 00000000000..555cf183574 --- /dev/null +++ b/apps/e2e/templates/components/LiveComponentWithDtoAndCustomHydrationMethods.html.twig @@ -0,0 +1,12 @@ + + + +
+ +

Address (print_r)

+ {{ print_r(address) }} + diff --git a/apps/e2e/templates/components/LiveComponentWithDtoAndHydrationExtension.html.twig b/apps/e2e/templates/components/LiveComponentWithDtoAndHydrationExtension.html.twig new file mode 100644 index 00000000000..514c1886b92 --- /dev/null +++ b/apps/e2e/templates/components/LiveComponentWithDtoAndHydrationExtension.html.twig @@ -0,0 +1,12 @@ + + + +
+ +

Point (print_r)

+ {{ print_r(point) }} + diff --git a/apps/e2e/templates/components/LiveComponentWithDtoAndSerializer.html.twig b/apps/e2e/templates/components/LiveComponentWithDtoAndSerializer.html.twig new file mode 100644 index 00000000000..555cf183574 --- /dev/null +++ b/apps/e2e/templates/components/LiveComponentWithDtoAndSerializer.html.twig @@ -0,0 +1,12 @@ + + + +
+ +

Address (print_r)

+ {{ print_r(address) }} + diff --git a/apps/e2e/templates/components/LiveComponentWithDtoCollection.html.twig b/apps/e2e/templates/components/LiveComponentWithDtoCollection.html.twig new file mode 100644 index 00000000000..2f59055c544 --- /dev/null +++ b/apps/e2e/templates/components/LiveComponentWithDtoCollection.html.twig @@ -0,0 +1,17 @@ + + + + +
+

Addresses (print_r)

+ {{ print_r(addresses) }} + diff --git a/apps/e2e/templates/components/LiveCounter.html.twig b/apps/e2e/templates/components/LiveCounter.html.twig new file mode 100644 index 00000000000..ac741f22a02 --- /dev/null +++ b/apps/e2e/templates/components/LiveCounter.html.twig @@ -0,0 +1,19 @@ + +
+ + + {{ value }} + + +
+ diff --git a/apps/e2e/templates/components/LiveExamplesSearch.html.twig b/apps/e2e/templates/components/LiveExamplesSearch.html.twig new file mode 100644 index 00000000000..ff26ba0387d --- /dev/null +++ b/apps/e2e/templates/components/LiveExamplesSearch.html.twig @@ -0,0 +1,44 @@ + +
+ +
+ + + + +
+ +
+
+ {% for package, examples in computed.examplesGroupedByPackage %} + {% set package = enum('App\\UxPackage').from(package) %} +

{{ package.value }} 📖

+
+ {% for example in examples %} +
+
+
+
{{ example.name }}
+

{{ example.description }}

+ See example +
+
+
+ {% endfor %} +
+ {% else %} + No results found. + {% endfor %} +
+ diff --git a/apps/e2e/templates/components/LiveFruitsPagination.html.twig b/apps/e2e/templates/components/LiveFruitsPagination.html.twig new file mode 100644 index 00000000000..5db302a627c --- /dev/null +++ b/apps/e2e/templates/components/LiveFruitsPagination.html.twig @@ -0,0 +1,22 @@ + +

Page {{ page }}

+
    + {% for fruit in this.fruits %} +
  • {{ fruit.name }}
  • + {% endfor %} +
+
+ + +
+ diff --git a/apps/e2e/templates/components/LiveItemList.html.twig b/apps/e2e/templates/components/LiveItemList.html.twig new file mode 100644 index 00000000000..b8a758e3b77 --- /dev/null +++ b/apps/e2e/templates/components/LiveItemList.html.twig @@ -0,0 +1,34 @@ + +
+ + + +
+ + {% if items|length > 0 %} +
    + {% for key, item in items %} +
  • + + + +
  • + {% endfor %} +
+ {% else %} +
No items.
+ {% endif %} + diff --git a/apps/e2e/templates/components/LiveRegistrationForm.html.twig b/apps/e2e/templates/components/LiveRegistrationForm.html.twig new file mode 100644 index 00000000000..5e2f1da01c6 --- /dev/null +++ b/apps/e2e/templates/components/LiveRegistrationForm.html.twig @@ -0,0 +1,27 @@ +{% form_theme form 'bootstrap_5_layout.html.twig' %} + + {% if isSuccessful %} +
+ Registration successful! +
+ {% else %} + {{ form_start(form, { + attr: { + novalidate: true, + 'data-action': 'live#action:prevent', + 'data-live-action-param': 'saveRegistration', + } + }) }} + {{ form_row(form.email) }} + {{ form_row(form.password) }} + + + + {{ form_rest(form) }} + {{ form_end(form) }} + {% endif %} + diff --git a/apps/e2e/templates/components/ProductionForm.html.twig b/apps/e2e/templates/components/ProductionForm.html.twig index 09d4fc18916..fb37b60d318 100644 --- a/apps/e2e/templates/components/ProductionForm.html.twig +++ b/apps/e2e/templates/components/ProductionForm.html.twig @@ -1,3 +1,4 @@ +{% form_theme form 'bootstrap_5_layout.html.twig' %}
{{ form_start(form) }}
diff --git a/apps/e2e/templates/home.html.twig b/apps/e2e/templates/home.html.twig index a5f188ab23c..3e0040a3086 100644 --- a/apps/e2e/templates/home.html.twig +++ b/apps/e2e/templates/home.html.twig @@ -13,22 +13,6 @@
- {% for package, examples in examples_by_package %} - {% set package = enum('App\\UxPackage').from(package) %} -

{{ package.value }} 📖

-
- {% for example in examples %} -
-
-
-
{{ example.name }}
-

{{ example.description }}

- See example -
-
-
- {% endfor %} -
- {% endfor %} +
{% endblock %} diff --git a/apps/e2e/templates/test/autocomplete_dynamic_form.html.twig b/apps/e2e/templates/test/autocomplete_dynamic_form.html.twig index d46786dd6a6..f262bd6b044 100644 --- a/apps/e2e/templates/test/autocomplete_dynamic_form.html.twig +++ b/apps/e2e/templates/test/autocomplete_dynamic_form.html.twig @@ -7,6 +7,6 @@

Autocomplete with Dynamic Forms

This test page demonstrates dynamic autocomplete fields within a LiveComponent form.

- {{ component('ProductionForm') }} +
{% endblock %} diff --git a/apps/e2e/templates/ux_live_component/counter.html.twig b/apps/e2e/templates/ux_live_component/counter.html.twig new file mode 100644 index 00000000000..b86b3ab7e09 --- /dev/null +++ b/apps/e2e/templates/ux_live_component/counter.html.twig @@ -0,0 +1,5 @@ +{% extends 'example.html.twig' %} + +{% block example %} + +{% endblock %} diff --git a/apps/e2e/templates/ux_live_component/fruits.html.twig b/apps/e2e/templates/ux_live_component/fruits.html.twig new file mode 100644 index 00000000000..8e462a75715 --- /dev/null +++ b/apps/e2e/templates/ux_live_component/fruits.html.twig @@ -0,0 +1,5 @@ +{% extends 'example.html.twig' %} + +{% block example %} + +{% endblock %} diff --git a/apps/e2e/templates/ux_live_component/index.html.twig b/apps/e2e/templates/ux_live_component/index.html.twig deleted file mode 100644 index 78c01e96007..00000000000 --- a/apps/e2e/templates/ux_live_component/index.html.twig +++ /dev/null @@ -1,3 +0,0 @@ -{% extends 'example.html.twig' %} - -{% block example %}{% endblock %} diff --git a/apps/e2e/templates/ux_live_component/item_list.html.twig b/apps/e2e/templates/ux_live_component/item_list.html.twig new file mode 100644 index 00000000000..6a1f90fee35 --- /dev/null +++ b/apps/e2e/templates/ux_live_component/item_list.html.twig @@ -0,0 +1,5 @@ +{% extends 'example.html.twig' %} + +{% block example %} + +{% endblock %} diff --git a/apps/e2e/templates/ux_live_component/registration_form.html.twig b/apps/e2e/templates/ux_live_component/registration_form.html.twig new file mode 100644 index 00000000000..65ad46ababc --- /dev/null +++ b/apps/e2e/templates/ux_live_component/registration_form.html.twig @@ -0,0 +1,5 @@ +{% extends 'example.html.twig' %} + +{% block example %} + +{% endblock %} diff --git a/apps/e2e/templates/ux_live_component/with_aliased_live_props.html.twig b/apps/e2e/templates/ux_live_component/with_aliased_live_props.html.twig new file mode 100644 index 00000000000..51e5d7bd49c --- /dev/null +++ b/apps/e2e/templates/ux_live_component/with_aliased_live_props.html.twig @@ -0,0 +1,5 @@ +{% extends 'example.html.twig' %} + +{% block example %} + +{% endblock %} diff --git a/apps/e2e/templates/ux_live_component/with_dto.html.twig b/apps/e2e/templates/ux_live_component/with_dto.html.twig new file mode 100644 index 00000000000..152c417cac2 --- /dev/null +++ b/apps/e2e/templates/ux_live_component/with_dto.html.twig @@ -0,0 +1,5 @@ +{% extends 'example.html.twig' %} + +{% block example %} + +{% endblock %} diff --git a/apps/e2e/templates/ux_live_component/with_dto_and_custom_hydration_methods.html.twig b/apps/e2e/templates/ux_live_component/with_dto_and_custom_hydration_methods.html.twig new file mode 100644 index 00000000000..4402fa18e47 --- /dev/null +++ b/apps/e2e/templates/ux_live_component/with_dto_and_custom_hydration_methods.html.twig @@ -0,0 +1,5 @@ +{% extends 'example.html.twig' %} + +{% block example %} + +{% endblock %} diff --git a/apps/e2e/templates/ux_live_component/with_dto_and_hydration_extension.html.twig b/apps/e2e/templates/ux_live_component/with_dto_and_hydration_extension.html.twig new file mode 100644 index 00000000000..49034186e18 --- /dev/null +++ b/apps/e2e/templates/ux_live_component/with_dto_and_hydration_extension.html.twig @@ -0,0 +1,5 @@ +{% extends 'example.html.twig' %} + +{% block example %} + +{% endblock %} diff --git a/apps/e2e/templates/ux_live_component/with_dto_and_serializer.html.twig b/apps/e2e/templates/ux_live_component/with_dto_and_serializer.html.twig new file mode 100644 index 00000000000..e6e02942def --- /dev/null +++ b/apps/e2e/templates/ux_live_component/with_dto_and_serializer.html.twig @@ -0,0 +1,5 @@ +{% extends 'example.html.twig' %} + +{% block example %} + +{% endblock %} diff --git a/apps/e2e/templates/ux_live_component/with_dto_collection.html.twig b/apps/e2e/templates/ux_live_component/with_dto_collection.html.twig new file mode 100644 index 00000000000..d385c205864 --- /dev/null +++ b/apps/e2e/templates/ux_live_component/with_dto_collection.html.twig @@ -0,0 +1,5 @@ +{% extends 'example.html.twig' %} + +{% block example %} + +{% endblock %} diff --git a/src/Autocomplete/assets/test/browser/dynamic-form.test.ts b/src/Autocomplete/assets/test/browser/dynamic-form.test.ts index ece6333021d..c11bff9a535 100644 --- a/src/Autocomplete/assets/test/browser/dynamic-form.test.ts +++ b/src/Autocomplete/assets/test/browser/dynamic-form.test.ts @@ -17,7 +17,7 @@ async function waitForAutocomplete(page: Page, testId: string) { test.describe('Autocomplete with Dynamic Forms', () => { test.beforeEach(async ({ page }) => { - await page.goto('/test/autocomplete-dynamic-form'); + await page.goto('/test-autocomplete/dynamic-form'); await expect(page.locator('[data-test-id="test-page"]')).toBeVisible(); }); diff --git a/src/LiveComponent/assets/test/browser/live-component.test.ts b/src/LiveComponent/assets/test/browser/live-component.test.ts new file mode 100644 index 00000000000..b67ac525abf --- /dev/null +++ b/src/LiveComponent/assets/test/browser/live-component.test.ts @@ -0,0 +1,562 @@ +import { expect, test } from '@playwright/test'; + +test('Can filters examples from homepage', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText("Symfony UX's E2E App")).toBeVisible(); + + // Ensure that some examples are initially visible + await expect(page).toHaveURL('/'); + await expect(page.getByRole('heading', { name: 'UX Autocomplete' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'UX Map' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'UX Translator' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Clear' })).toBeDisabled(); + + // Filter examples by "autocomplete" + await page.getByRole('textbox', { name: 'Search for examples:' }).fill('autocomplete'); + await page.waitForResponse('/_components/LiveExamplesSearch'); + + await expect(page.getByRole('heading', { name: 'UX Autocomplete' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'UX Map' })).not.toBeVisible(); + await expect(page.getByRole('heading', { name: 'UX Translator' })).not.toBeVisible(); + await expect(page, 'The URL must be updated with the query value').toHaveURL('/?query=autocomplete'); + await expect(page.getByRole('button', { name: 'Clear' })).toBeEnabled(); + + // Filter examples by "map" + await page.getByRole('textbox', { name: 'Search for examples:' }).fill('map'); + await page.waitForResponse('/_components/LiveExamplesSearch'); + + await expect(page.getByRole('heading', { name: 'UX Autocomplete' })).not.toBeVisible(); + await expect(page.getByRole('heading', { name: 'UX Map' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'UX Translator' })).not.toBeVisible(); + await expect(page, 'The URL must be updated with the query value').toHaveURL('/?query=map'); + await expect(page.getByRole('button', { name: 'Clear' })).toBeEnabled(); + + // Filter examples by a non-existing term + await page + .getByRole('textbox', { name: 'Search for examples:' }) + .fill("Les chaussettes de l'archiduchesse sont-elles sèches ou archi-sèches ?"); + await page.waitForResponse('/_components/LiveExamplesSearch'); + + await expect(page.getByText('No results found.')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'UX Autocomplete' })).not.toBeVisible(); + await expect(page.getByRole('heading', { name: 'UX Map' })).not.toBeVisible(); + await expect(page.getByRole('heading', { name: 'UX Translator' })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Clear' })).toBeEnabled(); + + // Reset the filter + await page.getByRole('button', { name: 'Clear' }).click(); + await page.waitForResponse('/_components/LiveExamplesSearch/clearQuery'); + + await expect(page.getByRole('heading', { name: 'UX Autocomplete' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'UX Map' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'UX Translator' })).toBeVisible(); + await expect(page, 'The URL must be reset').toHaveURL('/?query='); + await expect(page.getByRole('button', { name: 'Clear' })).toBeDisabled(); +}); + +test('Can mutate a LiveProp(url: true), the state must be persisted in the URL, as a query parameter', async ({ + page, +}) => { + await page.goto('/ux-live-component/counter'); + await expect(page.getByTestId('value')).toHaveText('0'); + await expect(page).toHaveURL('/ux-live-component/counter'); + + // Increment the counter + await page.getByRole('button', { name: '+' }).click(); + await page.waitForResponse('/_components/LiveCounter/increment'); + await expect(page.getByTestId('value')).toHaveText('1'); + await expect(page).toHaveURL('/ux-live-component/counter?value=1'); + + // Increment the counter again + await page.getByRole('button', { name: '+' }).click(); + await page.waitForResponse('/_components/LiveCounter/increment'); + await expect(page.getByTestId('value')).toHaveText('2'); + await expect(page).toHaveURL('/ux-live-component/counter?value=2'); + + // Decrement the counter + await page.getByRole('button', { name: '-' }).click(); + await page.waitForResponse('/_components/LiveCounter/decrement'); + await expect(page.getByTestId('value')).toHaveText('1'); + await expect(page).toHaveURL('/ux-live-component/counter?value=1'); + + // Reload the page to ensure state is preserved + await page.reload(); + await expect(page.getByTestId('value')).toHaveText('1'); + await expect(page).toHaveURL('/ux-live-component/counter?value=1'); +}); + +test('Can mutate a LiveProp(url: new UrlMapping(mapPath: true)), the state must be persisted in the URL, as a path parameter', async ({ + page, +}) => { + await page.goto('/ux-live-component/fruits'); + + const buttonPrevious = page.getByRole('button', { name: 'Previous' }); + const buttonNext = page.getByRole('button', { name: 'Next' }); + + await expect(page.getByRole('heading', { name: 'Page 1' })).toBeVisible(); + await expect(page.getByText('Apple')).toBeVisible(); + await expect(page.getByText('Banana')).toBeVisible(); + await expect(page.getByText('Cherry')).toBeVisible(); + await expect(page.getByText('Coconut')).toBeVisible(); + await expect(page.getByText('Grape')).toBeVisible(); + await expect(buttonPrevious).toBeDisabled(); + await expect(buttonNext).toBeEnabled(); + + // Go to next page + await buttonNext.click(); + await page.waitForResponse('/_components/LiveFruitsPagination/goToNextPage'); + await expect(page.getByRole('heading', { name: 'Page 2' })).toBeVisible(); + await expect(page.getByText('Kiwi')).toBeVisible(); + await expect(page.getByText('Lemon')).toBeVisible(); + await expect(page.getByText('Mango')).toBeVisible(); + await expect(page.getByText('Orange')).toBeVisible(); + await expect(page.getByText('Papaya')).toBeVisible(); + await expect(buttonPrevious).toBeEnabled(); + await expect(buttonNext).toBeEnabled(); + await expect(page).toHaveURL('/ux-live-component/fruits/2'); + + // Go to next page + await buttonNext.click(); + await page.waitForResponse('/_components/LiveFruitsPagination/goToNextPage'); + await expect(page.getByRole('heading', { name: 'Page 3' })).toBeVisible(); + await expect(page.getByText('Peach')).toBeVisible(); + await expect(page.getByText('Pineapple')).toBeVisible(); + await expect(page.getByText('Pear')).toBeVisible(); + await expect(page.getByText('Pomegranate')).toBeVisible(); + await expect(page.getByText('Pomelo')).toBeVisible(); + await expect(buttonPrevious).toBeEnabled(); + await expect(buttonNext).toBeDisabled(); + await expect(page).toHaveURL('/ux-live-component/fruits/3'); + + // Go back to previous page + await buttonPrevious.click(); + await page.waitForResponse('/_components/LiveFruitsPagination/goToPreviousPage'); + await expect(page.getByRole('heading', { name: 'Page 2' })).toBeVisible(); + await expect(page.getByText('Kiwi')).toBeVisible(); + await expect(page.getByText('Lemon')).toBeVisible(); + await expect(page.getByText('Mango')).toBeVisible(); + await expect(page.getByText('Orange')).toBeVisible(); + await expect(page.getByText('Papaya')).toBeVisible(); + await expect(buttonPrevious).toBeEnabled(); + await expect(buttonNext).toBeEnabled(); + await expect(page).toHaveURL('/ux-live-component/fruits/2'); + + // Go explicitly to page 1 + await page.goto('/ux-live-component/fruits/1'); + await expect(page.getByRole('heading', { name: 'Page 1' })).toBeVisible(); + await expect(page.getByText('Apple')).toBeVisible(); + await expect(page.getByText('Banana')).toBeVisible(); + await expect(page.getByText('Cherry')).toBeVisible(); + await expect(page.getByText('Coconut')).toBeVisible(); + await expect(page.getByText('Grape')).toBeVisible(); + await expect(buttonPrevious).toBeDisabled(); + await expect(buttonNext).toBeEnabled(); + + // Reload the page to ensure state is preserved + await page.goto('/ux-live-component/fruits/1'); + await expect(page.getByRole('heading', { name: 'Page 1' })).toBeVisible(); + await expect(page.getByText('Apple')).toBeVisible(); + await expect(page.getByText('Banana')).toBeVisible(); + await expect(page.getByText('Cherry')).toBeVisible(); + await expect(page.getByText('Coconut')).toBeVisible(); + await expect(page.getByText('Grape')).toBeVisible(); + await expect(buttonPrevious).toBeDisabled(); + await expect(buttonNext).toBeEnabled(); +}); + +test('Can mutate a LiveProp(url: new UrlMapping(mapPath: true)), the state must be persisted in the URL, as a path parameter, and unknown query parameters must be preserved', async ({ + page, +}) => { + await page.goto('/ux-live-component/fruits/2?foo=bar'); + + const buttonPrevious = page.getByRole('button', { name: 'Previous' }); + const buttonNext = page.getByRole('button', { name: 'Next' }); + + await expect(page.getByRole('heading', { name: 'Page 2' })).toBeVisible(); + await expect(page.getByText('Kiwi')).toBeVisible(); + await expect(page.getByText('Lemon')).toBeVisible(); + await expect(page.getByText('Mango')).toBeVisible(); + await expect(page.getByText('Orange')).toBeVisible(); + await expect(page.getByText('Papaya')).toBeVisible(); + await expect(buttonPrevious).toBeEnabled(); + await expect(buttonNext).toBeEnabled(); + await expect(page).toHaveURL('/ux-live-component/fruits/2?foo=bar'); + + // Go to next page + await buttonNext.click(); + await page.waitForResponse('/_components/LiveFruitsPagination/goToNextPage'); + await expect(page.getByRole('heading', { name: 'Page 3' })).toBeVisible(); + await expect(page.getByText('Peach')).toBeVisible(); + await expect(page.getByText('Pineapple')).toBeVisible(); + await expect(page.getByText('Pear')).toBeVisible(); + await expect(page.getByText('Pomegranate')).toBeVisible(); + await expect(page.getByText('Pomelo')).toBeVisible(); + await expect(buttonPrevious).toBeEnabled(); + await expect(buttonNext).toBeDisabled(); + await expect(page).toHaveURL('/ux-live-component/fruits/3?foo=bar'); +}); + +test('Form auto-validation', async ({ page }) => { + await page.goto('/ux-live-component/registration-form'); + + const fieldEmail = page.getByRole('textbox', { name: 'Email' }); + const fieldPassword = page.getByRole('textbox', { name: 'Password' }); + const buttonSubmit = page.getByRole('button', { name: 'Register' }); + + // Let's submit the form without filling in any values + await buttonSubmit.click(); + await page.waitForResponse('/_components/LiveRegistrationForm/saveRegistration'); + + await expect(page.getByText('This value should not be blank.')).toHaveCount(2); + await expect(buttonSubmit).toBeDisabled(); + + // Fill the email only (with an invalid value) + await fieldEmail.fill('invalid-email'); + await fieldEmail.blur(); + await page.waitForResponse('/_components/LiveRegistrationForm'); + + await expect(page.getByText('This value is not a valid email address.')).toHaveCount(1); + await expect(page.getByText('This value should not be blank.')).toHaveCount(1); + await expect(buttonSubmit).toBeDisabled(); + + // Fill the email with a valid value + await fieldEmail.fill('hugo@alliau.me'); + await fieldEmail.blur(); + await page.waitForResponse('/_components/LiveRegistrationForm'); + + await expect(page.getByText('This value is not a valid email address.')).toHaveCount(0); + await expect(page.getByText('This value should not be blank.')).toHaveCount(1); + await expect(buttonSubmit).toBeDisabled(); + + // Fill the password with a too short value + await fieldPassword.fill('short'); + await fieldPassword.blur(); + await page.waitForResponse('/_components/LiveRegistrationForm'); + + await expect(page.getByText('This value is too short. It should have 8 characters or more.')).toHaveCount(1); + await expect(page.getByRole('button', { name: 'Register' })).toBeDisabled(); + + // Fill the password with a valid value + await fieldPassword.fill('a-very-secure-password'); + await fieldPassword.blur(); + await page.waitForResponse('/_components/LiveRegistrationForm'); + + await expect(page.getByText('This value is too short. It should have 8 characters or more.')).toHaveCount(0); + await expect(buttonSubmit).toBeEnabled(); + + // Submit the form + await buttonSubmit.click(); + await page.waitForResponse('/_components/LiveRegistrationForm/saveRegistration'); + + await expect(page.getByText('Registration successful!')).toBeVisible(); +}); + +test('With a DTO as a LiveProp, correctly generates the query parameter', async ({ page }) => { + await page.goto('/ux-live-component/with-dto'); + + const fieldCountry = page.getByRole('textbox', { name: 'Country' }); + const fieldCity = page.getByRole('textbox', { name: 'City' }); + const output = page.locator('output'); + + await expect(fieldCountry).toHaveValue('France'); + await expect(fieldCity).toHaveValue('Lyon'); + await expect(output).toHaveText(` +App\\Model\\Address Object +( + [country] => France + [city] => Lyon +) +`); + await expect(page).toHaveURL('/ux-live-component/with-dto'); + + // Update country + await fieldCountry.fill('South Korea'); + await page.waitForResponse('/_components/LiveComponentWithDto'); + + await expect(fieldCountry).toHaveValue('South Korea'); + await expect(fieldCity).toHaveValue('Lyon'); + await expect(output).toHaveText(` +App\\Model\\Address Object +( + [country] => South Korea + [city] => Lyon +) +`); + await expect(page).toHaveURL('/ux-live-component/with-dto?address%5Bcountry%5D=South+Korea&address%5Bcity%5D=Lyon'); + + // Update city + await fieldCity.fill('Seoul'); + await page.waitForResponse('/_components/LiveComponentWithDto'); + + await expect(fieldCountry).toHaveValue('South Korea'); + await expect(fieldCity).toHaveValue('Seoul'); + await expect(output).toHaveText(` +App\\Model\\Address Object +( + [country] => South Korea + [city] => Seoul +) +`); + await expect(page).toHaveURL( + '/ux-live-component/with-dto?address%5Bcountry%5D=South+Korea&address%5Bcity%5D=Seoul' + ); +}); + +test('With a DTO as a LiveProp, initializes its state from the URL query parameter', async ({ page }) => { + await page.goto('/ux-live-component/with-dto?address%5Bcountry%5D=Japan&address%5Bcity%5D=Tokyo'); + + const fieldCountry = page.getByRole('textbox', { name: 'Country' }); + const fieldCity = page.getByRole('textbox', { name: 'City' }); + const output = page.locator('output'); + + await expect(fieldCountry).toHaveValue('Japan'); + await expect(fieldCity).toHaveValue('Tokyo'); + await expect(output).toHaveText(` +App\\Model\\Address Object +( + [country] => Japan + [city] => Tokyo +) +`); +}); + +test('With a DTO as a LiveProp, keep unknown query parameters', async ({ page }) => { + // We add an extra "foo=bar" query parameter that the component does not know about + await page.goto('/ux-live-component/with-dto?address%5Bcountry%5D=Italy&address%5Bcity%5D=Rome&foo=bar'); + + const fieldCountry = page.getByRole('textbox', { name: 'Country' }); + const fieldCity = page.getByRole('textbox', { name: 'City' }); + const output = page.locator('output'); + + await expect(fieldCountry).toHaveValue('Italy'); + await expect(fieldCity).toHaveValue('Rome'); + await expect(output).toHaveText(` +App\\Model\\Address Object +( + [country] => Italy + [city] => Rome +) +`); + + // Update city + await fieldCity.fill('Milan'); + await page.waitForResponse('/_components/LiveComponentWithDto'); + + await expect(fieldCountry).toHaveValue('Italy'); + await expect(fieldCity).toHaveValue('Milan'); + await expect(output).toHaveText(` +App\\Model\\Address Object +( + [country] => Italy + [city] => Milan +) +`); + // The "foo=bar" query parameter must be preserved + await expect(page).toHaveURL( + '/ux-live-component/with-dto?address%5Bcountry%5D=Italy&address%5Bcity%5D=Milan&foo=bar' + ); +}); + +test('With a DTO Collection as a LiveProp, correctly generates the query parameter', async ({ page }) => { + await page.goto('/ux-live-component/with-dto-collection'); + + const buttonAddAddress = page.getByRole('button', { name: 'Add address' }); + const output = page.locator('output'); + + await expect(output).toHaveText(` +Array +( +) +`); + await expect(page).toHaveURL('/ux-live-component/with-dto-collection'); + + // Add first address + await buttonAddAddress.click(); + await page.waitForResponse('/_components/LiveComponentWithDtoCollection/addAddress'); + + await expect(output).toHaveText(` +Array +( + [0] => App\\Model\\Address Object + ( + [country] => France + [city] => Lyon + ) + +) +`); + await expect(page).toHaveURL( + '/ux-live-component/with-dto-collection?addresses%5B0%5D%5Bcountry%5D=France&addresses%5B0%5D%5Bcity%5D=Lyon' + ); + + // Add second address + await buttonAddAddress.click(); + await page.waitForResponse('/_components/LiveComponentWithDtoCollection/addAddress'); + + await expect(output).toHaveText(` +Array +( + [0] => App\\Model\\Address Object + ( + [country] => France + [city] => Lyon + ) + + [1] => App\\Model\\Address Object + ( + [country] => South Korea + [city] => Seoul + ) + +) +`); + await expect(page).toHaveURL( + '/ux-live-component/with-dto-collection?addresses%5B0%5D%5Bcountry%5D=France&addresses%5B0%5D%5Bcity%5D=Lyon&addresses%5B1%5D%5Bcountry%5D=South+Korea&addresses%5B1%5D%5Bcity%5D=Seoul' + ); + + // Reset + await page.getByRole('button', { name: 'Reset' }).click(); + await page.waitForResponse('/_components/LiveComponentWithDtoCollection/reset'); + + await expect(output).toHaveText(` +Array +( +) +`); + await expect(page).toHaveURL('/ux-live-component/with-dto-collection'); +}); + +test('With a DTO as a LiveProp, with the Symfony Serializer', async ({ page }) => { + await page.goto('/ux-live-component/with-dto-and-serializer'); + + await page.getByRole('button', { name: 'Init address' }).click(); + await page.waitForResponse('/_components/LiveComponentWithDtoAndSerializer/initAddress'); + + await expect(page.locator('output')).toHaveText(` +App\\Model\\Address Object +( + [country] => France + [city] => Lyon +) +`); + await expect(page).toHaveURL( + '/ux-live-component/with-dto-and-serializer?address%5Bserialized_country%5D=France&address%5Bserialized_city%5D=Lyon' + ); +}); + +test('With a DTO as a LiveProp, with custom methods to hydrate/dehydrate the DTO', async ({ page }) => { + await page.goto('/ux-live-component/with-dto-and-custom-hydration-methods'); + + await page.getByRole('button', { name: 'Init address' }).click(); + await page.waitForResponse('/_components/LiveComponentWithDtoAndCustomHydrationMethods/initAddress'); + + await expect(page.locator('output')).toHaveText(` +App\\Model\\Address Object +( + [country] => France + [city] => Lyon +) +`); + await expect(page).toHaveURL( + '/ux-live-component/with-dto-and-custom-hydration-methods?address%5Bx-country%5D=France&address%5Bx-city%5D=Lyon' + ); +}); + +test('With a DTO as a LiveProp, with custom hydration extension', async ({ page }) => { + await page.goto('/ux-live-component/with-dto-and-hydration-extension'); + + await page.getByRole('button', { name: 'Init point' }).click(); + await page.waitForResponse('/_components/LiveComponentWithDtoAndHydrationExtension/initPoint'); + + await expect(page.locator('output')).toHaveText(` +App\\Model\\Point Object +( + [x] => 69.42 + [y] => -1.337 +) +`); + await expect(page).toHaveURL( + '/ux-live-component/with-dto-and-hydration-extension?point%5Bpx%5D=69.42&point%5Bpy%5D=-1.337' + ); +}); + +test('Item list, can add item, modify item, remove a specific item or all items at once', async ({ page }) => { + await page.goto('ux-live-component/item-list'); + + const buttonAddItem = page.getByRole('button', { name: 'Add item' }); + const buttonDeleteAll = page.getByRole('button', { name: 'Delete all ' }); + + await expect(page.getByText('No items.')).toBeVisible(); + + // Add one item + await buttonAddItem.click(); + await page.waitForResponse('/_components/LiveItemList/addItem'); + + await expect(page).toHaveURL('/ux-live-component/item-list?items%5B0%5D='); + await expect(page.getByText('No items.')).not.toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Item #0' })).toHaveValue(''); + + await page.getByRole('textbox', { name: 'Item #0' }).fill('The first item'); + await page.waitForResponse('/_components/LiveItemList'); + + await expect(page).toHaveURL('/ux-live-component/item-list?items%5B0%5D=The+first+item'); + await expect(page.getByRole('textbox', { name: 'Item #0' })).toHaveValue('The first item'); + + // Add two more items + await buttonAddItem.click(); + await page.waitForResponse('/_components/LiveItemList/addItem'); + await buttonAddItem.click(); + await page.waitForResponse('/_components/LiveItemList/addItem'); + + await expect(page).toHaveURL( + '/ux-live-component/item-list?items%5B0%5D=The+first+item&items%5B1%5D=&items%5B2%5D=' + ); + await expect(page.getByRole('textbox', { name: 'Item #0' })).toHaveValue('The first item'); + await expect(page.getByRole('textbox', { name: 'Item #1' })).toHaveValue(''); + await expect(page.getByRole('textbox', { name: 'Item #2' })).toHaveValue(''); + + // Fill the last item + await page.getByRole('textbox', { name: 'Item #2' }).fill('The last item'); + await page.waitForResponse('/_components/LiveItemList'); + + await expect(page).toHaveURL( + '/ux-live-component/item-list?items%5B0%5D=The+first+item&items%5B1%5D=&items%5B2%5D=The+last+item' + ); + await expect(page.getByRole('textbox', { name: 'Item #2' })).toHaveValue('The last item'); + + // Delete the 2nd item + await page.getByTitle('Delete item #1').click(); + await page.waitForResponse('/_components/LiveItemList/deleteItem'); + + await expect(page).toHaveURL('/ux-live-component/item-list?items%5B0%5D=The+first+item&items%5B2%5D=The+last+item'); + await expect(page.getByRole('textbox', { name: 'Item #0' })).toHaveValue('The first item'); + await expect(page.getByRole('textbox', { name: 'Item #2' })).toHaveValue('The last item'); + + // Delete all items + await buttonDeleteAll.click(); + await page.waitForResponse('/_components/LiveItemList/deleteItems'); + + await expect(page).toHaveURL('/ux-live-component/item-list'); + await expect(page.getByText('No items.')).toBeVisible(); +}); + +test('LiveProp should be aliased', async ({ page }) => { + await page.goto('/ux-live-component/with-aliased-live-props'); + + const fieldQuery = page.getByRole('textbox', { name: 'Query' }); + const fieldCategory = page.getByRole('textbox', { name: 'Category' }); + + // Fill "query" + await fieldQuery.fill('Symfony is great!'); + await page.waitForResponse('/_components/LiveComponentWithAliasedLiveProps'); + + await expect(page).toHaveURL('/ux-live-component/with-aliased-live-props?q=Symfony+is+great%21&cat='); + + // Fill "category" + await fieldCategory.fill('Web development'); + await page.waitForResponse('/_components/LiveComponentWithAliasedLiveProps'); + + await expect(page).toHaveURL( + '/ux-live-component/with-aliased-live-props?q=Symfony+is+great%21&cat=Web+development' + ); +}); diff --git a/src/LiveComponent/assets/test/browser/placeholder.test.ts b/src/LiveComponent/assets/test/browser/placeholder.test.ts deleted file mode 100644 index f5f1d10f22e..00000000000 --- a/src/LiveComponent/assets/test/browser/placeholder.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('Can see homepage', async ({ page }) => { - await page.goto('/'); - - await expect(page.getByText("Symfony UX's E2E App")).toBeVisible(); -});