diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml index 76971211296..59216da3bbc 100644 --- a/.github/workflows/browser-tests.yml +++ b/.github/workflows/browser-tests.yml @@ -73,8 +73,6 @@ jobs: - name: Configure E2E app run: | - echo 'APP_ENV=prod' >> .env.local - echo 'APP_DEBUG=0' >> .env.local echo 'APP_SECRET=df4c071596e64cc75a349456f2887ae2419ae650' >> .env.local working-directory: apps/e2e @@ -83,11 +81,12 @@ jobs: with: working-directory: apps/e2e dependency-versions: highest - composer-options: --no-dev custom-cache-suffix: symfony-${{ matrix.symfony }} - name: Prepare E2E app run: | + echo 'APP_ENV=prod' >> .env.local + echo 'APP_DEBUG=0' >> .env.local symfony composer dump-autoload --classmap-authoritative --no-dev symfony composer dump-env symfony console asset-map:compile diff --git a/apps/e2e/.env b/apps/e2e/.env index c5badfcd659..ae8e8d47fba 100644 --- a/apps/e2e/.env +++ b/apps/e2e/.env @@ -23,7 +23,7 @@ APP_SECRET= # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml # -DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" +DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" # DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8" 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/README.md b/apps/e2e/README.md index 4795b30333d..1205bf2a9eb 100644 --- a/apps/e2e/README.md +++ b/apps/e2e/README.md @@ -1,8 +1,8 @@ # E2E App -This is a Symfony application designed for end-to-end testing. +This is a Symfony application designed for end-to-end testing. -It serves for testing UX packages in a real-world scenario, +It serves for testing UX packages in a real-world scenario, to ensure they work as expected for multiple Symfony versions and various browsers. ## Requirements @@ -16,7 +16,7 @@ to ensure they work as expected for multiple Symfony versions and various browse ```shell docker compose up -d -symfony php ../.github/build-packages.php +symfony php ../../.github/build-packages.php SYMFONY_REQUIRE=6.4.* symfony composer update # or... diff --git a/apps/e2e/assets/app.js b/apps/e2e/assets/app.js index 5b1ceb0c4e7..a237aff12dd 100644 --- a/apps/e2e/assets/app.js +++ b/apps/e2e/assets/app.js @@ -2,6 +2,8 @@ import { registerVueControllerComponents } from '@symfony/ux-vue'; import { registerSvelteControllerComponents } from '@symfony/ux-svelte'; import { registerReactControllerComponents } from '@symfony/ux-react'; import './bootstrap.js'; +import { trans } from "./translator.js"; + /* * Welcome to your app's main JavaScript file! * @@ -16,3 +18,5 @@ console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉'); registerReactControllerComponents(); registerSvelteControllerComponents(); registerVueControllerComponents(); + +export { trans }; diff --git a/apps/e2e/assets/controllers/movie-autocomplete_controller.js b/apps/e2e/assets/controllers/movie-autocomplete_controller.js new file mode 100644 index 00000000000..be8682958c3 --- /dev/null +++ b/apps/e2e/assets/controllers/movie-autocomplete_controller.js @@ -0,0 +1,35 @@ +import { Controller } from '@hotwired/stimulus'; +import { getComponent } from '@symfony/ux-live-component'; + +export default class extends Controller { + async connect() { + this.component = await getComponent(this.element.closest('[data-controller*="live"]')); + + this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect.bind(this)); + this.element.addEventListener('autocomplete:connect', this._onConnect.bind(this)); + } + + disconnect() { + this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect.bind(this)); + this.element.removeEventListener('autocomplete:connect', this._onConnect.bind(this)); + } + + _onPreConnect(event) { + const options = event.detail.options; + options.render = { + ...options.render, + option: (item) => { + return `
${item.text}
`; + }, + }; + } + + _onConnect(event) { + const tomSelect = event.detail.tomSelect; + + tomSelect.on('item_add', (value, item) => { + const title = item.getAttribute('data-title') || item.textContent; + this.component.emit('movie-selected', { title }); + }); + } +} diff --git a/apps/e2e/assets/controllers/videogame-autocomplete_controller.js b/apps/e2e/assets/controllers/videogame-autocomplete_controller.js new file mode 100644 index 00000000000..0b30e1c1eba --- /dev/null +++ b/apps/e2e/assets/controllers/videogame-autocomplete_controller.js @@ -0,0 +1,35 @@ +import { Controller } from '@hotwired/stimulus'; +import { getComponent } from '@symfony/ux-live-component'; + +export default class extends Controller { + async connect() { + this.component = await getComponent(this.element.closest('[data-controller*="live"]')); + + this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect.bind(this)); + this.element.addEventListener('autocomplete:connect', this._onConnect.bind(this)); + } + + disconnect() { + this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect.bind(this)); + this.element.removeEventListener('autocomplete:connect', this._onConnect.bind(this)); + } + + _onPreConnect(event) { + const options = event.detail.options; + options.render = { + ...options.render, + option: (item) => { + return `
${item.text}
`; + }, + }; + } + + _onConnect(event) { + const tomSelect = event.detail.tomSelect; + + tomSelect.on('item_add', (value, item) => { + const title = item.getAttribute('data-title') || item.textContent; + this.component.emit('videogame-selected', { title }); + }); + } +} 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/assets/translator.js b/apps/e2e/assets/translator.js index a0efa830ae4..320d791650b 100644 --- a/apps/e2e/assets/translator.js +++ b/apps/e2e/assets/translator.js @@ -1,5 +1,6 @@ -import { localeFallbacks } from '@app/translations/configuration'; -import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-translator'; +import { createTranslator } from "@symfony/ux-translator"; +import { messages, localeFallbacks } from "../var/translations/index.js"; + /* * This file is part of the Symfony UX Translator package. * @@ -9,8 +10,9 @@ import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-tra * If you use TypeScript, you can rename this file to "translator.ts" to take advantage of types checking. */ -setLocaleFallbacks(localeFallbacks); - -export { trans }; +export const translator = createTranslator({ + messages, + localeFallbacks, +}); -export * from '@app/translations'; +export const { trans, setLocale } = translator; diff --git a/apps/e2e/composer.json b/apps/e2e/composer.json index b22dc2bc498..8a868cfc154 100644 --- a/apps/e2e/composer.json +++ b/apps/e2e/composer.json @@ -7,7 +7,8 @@ "auto-scripts": { "cache:clear": "symfony-cmd", "assets:install %PUBLIC_DIR%": "symfony-cmd", - "importmap:install": "symfony-cmd" + "importmap:install": "symfony-cmd", + "foundry:load-fixtures": "symfony-cmd" }, "post-install-cmd": [ "@auto-scripts" @@ -37,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", @@ -61,6 +65,7 @@ "symfony/ux-typed": "^2.29.1", "symfony/ux-vue": "^2.29.1", "symfony/yaml": "6.4.*|7.3.*", + "symfonycasts/dynamic-forms": "^0.2", "twig/extra-bundle": "^3.21", "twig/twig": "^3.21.1" }, @@ -68,7 +73,8 @@ "symfony/debug-bundle": "6.4.*|7.3.*", "symfony/maker-bundle": "^1.64", "symfony/stopwatch": "6.4.*|7.3.*", - "symfony/web-profiler-bundle": "6.4.*|7.3.*" + "symfony/web-profiler-bundle": "6.4.*|7.3.*", + "zenstruck/foundry": "^2.8" }, "config": { "platform": { diff --git a/apps/e2e/config/bundles.php b/apps/e2e/config/bundles.php index a90e1a7edd1..0354973075c 100644 --- a/apps/e2e/config/bundles.php +++ b/apps/e2e/config/bundles.php @@ -30,4 +30,5 @@ Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true], Symfony\UX\Typed\TypedBundle::class => ['all' => true], Symfony\UX\Vue\VueBundle::class => ['all' => true], + Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true], ]; diff --git a/apps/e2e/config/packages/zenstruck_foundry.yaml b/apps/e2e/config/packages/zenstruck_foundry.yaml new file mode 100644 index 00000000000..2f60dd01843 --- /dev/null +++ b/apps/e2e/config/packages/zenstruck_foundry.yaml @@ -0,0 +1,16 @@ +when@dev: &dev + # See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration + zenstruck_foundry: + persistence: + # Flush only once per call of `PersistentObjectFactory::create()` + flush_once: true + + # If you use the `make:factory --test` command, you may need to uncomment the following. + # See https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#generate + #services: + # App\Tests\Factory\: + # resource: '%kernel.project_dir%/tests/Factory/' + # autowire: true + # autoconfigure: true + +when@test: *dev diff --git a/apps/e2e/importmap.php b/apps/e2e/importmap.php index fd47adc87d8..a522562e05b 100644 --- a/apps/e2e/importmap.php +++ b/apps/e2e/importmap.php @@ -139,12 +139,6 @@ '@symfony/ux-translator' => [ 'path' => './vendor/symfony/ux-translator/assets/dist/translator_controller.js', ], - '@app/translations' => [ - 'path' => './var/translations/index.js', - ], - '@app/translations/configuration' => [ - 'path' => './var/translations/configuration.js', - ], 'typed.js' => [ 'version' => '2.1.0', ], diff --git a/apps/e2e/public/images/example.jpg b/apps/e2e/public/images/example.jpg new file mode 100644 index 00000000000..49649fb6ca3 Binary files /dev/null and b/apps/e2e/public/images/example.jpg differ diff --git a/apps/e2e/src/Controller/AutocompleteController.php b/apps/e2e/src/Controller/AutocompleteController.php index 42d875fe685..2e1f21d99c3 100644 --- a/apps/e2e/src/Controller/AutocompleteController.php +++ b/apps/e2e/src/Controller/AutocompleteController.php @@ -2,14 +2,67 @@ namespace App\Controller; -use Psr\Log\LoggerInterface; +use App\Form\FruitAutocompleteField; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\UX\Chartjs\Builder\ChartBuilderInterface; -use Symfony\UX\Chartjs\Model\Chart; -#[Route('/ux-autocomplete')] +#[Route('/ux-autocomplete', name: 'app_ux_autocomplete_')] final class AutocompleteController extends AbstractController { + #[Route('/without-ajax', name: 'without_ajax')] + public function withoutAjax(): Response + { + $formBuilder = $this->createFormBuilder(); + $formBuilder->add('favorite_fruit', ChoiceType::class, [ + 'autocomplete' => true, + 'label' => 'Your favorite fruit:', + 'choices' => [ + 'Apple' => 'apple', + 'Banana' => 'banana', + 'Cherry' => 'cherry', + 'Coconut' => 'coconut', + 'Grape' => 'grape', + 'Kiwi' => 'kiwi', + 'Lemon' => 'lemon', + 'Mango' => 'mango', + 'Orange' => 'orange', + 'Papaya' => 'papaya', + 'Peach' => 'peach', + 'Pineapple' => 'pineapple', + 'Pear' => 'pear', + 'Pomegranate' => 'pomegranate', + 'Pomelo' => 'pomelo', + 'Raspberry' => 'raspberry', + 'Strawberry' => 'strawberry', + 'Watermelon' => 'watermelon', + ], + ]); + + $form = $formBuilder->getForm(); + + return $this->render('ux_autocomplete/without_ajax.html.twig', [ + 'form' => $form->createView() + ]); + } + + #[Route('/with-ajax', name: 'with_ajax')] + public function withAjax(): Response + { + $formBuilder = $this->createFormBuilder(); + $formBuilder->add('favorite_fruit', FruitAutocompleteField::class); + + $form = $formBuilder->getForm(); + + return $this->render('ux_autocomplete/with_ajax.html.twig', [ + 'form' => $form->createView() + ]); + } + + #[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..f5f63564abe 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); @@ -58,4 +58,69 @@ public function withOptions(ChartBuilderInterface $chartBuilder): Response 'chart' => $chart, ]); } + + #[Route('/pie', name: 'pie')] + public function pie(ChartBuilderInterface $chartBuilder): Response + { + $chart = $chartBuilder->createChart(Chart::TYPE_PIE); + + $chart->setData([ + 'labels' => ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'], + 'datasets' => [ + [ + 'label' => 'My First Dataset', + 'data' => [12, 19, 3, 5, 2, 3], + 'backgroundColor' => [ + 'rgb(255, 99, 132)', + 'rgb(54, 162, 235)', + 'rgb(255, 205, 86)', + 'rgb(75, 192, 192)', + 'rgb(153, 102, 255)', + 'rgb(255, 159, 64)', + ], + ], + ], + ]); + + return $this->render('ux_chartjs/index.html.twig', [ + 'chart' => $chart, + ]); + } + + #[Route('/pie-with-options', name: 'pie_with_options')] + public function pieWithOptions(ChartBuilderInterface $chartBuilder): Response + { + $chart = $chartBuilder->createChart(Chart::TYPE_PIE); + + $chart->setData([ + 'labels' => ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'], + 'datasets' => [ + [ + 'label' => 'My First Dataset', + 'data' => [12, 19, 3, 5, 2, 3], + 'backgroundColor' => [ + 'rgb(255, 99, 132)', + 'rgb(54, 162, 235)', + 'rgb(255, 205, 86)', + 'rgb(75, 192, 192)', + 'rgb(153, 102, 255)', + 'rgb(255, 159, 64)', + ], + ], + ], + ]); + + $chart->setOptions([ + 'responsive' => true, + 'plugins' => [ + 'legend' => [ + 'position' => 'top', + ], + ], + ]); + + return $this->render('ux_chartjs/index.html.twig', [ + 'chart' => $chart, + ]); + } } diff --git a/apps/e2e/src/Controller/CropperjsController.php b/apps/e2e/src/Controller/CropperjsController.php index cf9363c4dbc..7ea31d9dbac 100644 --- a/apps/e2e/src/Controller/CropperjsController.php +++ b/apps/e2e/src/Controller/CropperjsController.php @@ -3,17 +3,77 @@ namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\UX\Cropperjs\Factory\CropperInterface; +use Symfony\UX\Cropperjs\Form\CropperType; -#[Route('/ux-cropperjs')] +#[Route('/ux-cropperjs', name: 'app_ux_cropperjs_')] final class CropperjsController extends AbstractController { - #[Route('/')] - public function index(): Response + public function __construct( + #[Autowire('%kernel.project_dir%/public')] + private string $publicDir + ) {} + + #[Route('/crop', name: 'crop')] + public function crop(CropperInterface $cropper, Request $request): Response { - return $this->render('ux_cropperjs/index.html.twig', [ - 'controller_name' => 'CropperjsController', + $crop = $cropper->createCrop($this->publicDir . '/images/example.jpg'); + $crop->setCroppedMaxSize(800, 600); + + $form = $this->createFormBuilder(['crop' => $crop]) + ->add('crop', CropperType::class, [ + 'public_url' => '/images/example.jpg', + 'cropper_options' => [ + 'viewMode' => 1, + ], + ]) + ->getForm(); + + $form->handleRequest($request); + + $croppedImageData = null; + if ($form->isSubmitted() && $form->isValid()) { + // Get the cropped image as base64 + $croppedImageData = base64_encode($crop->getCroppedImage()); + } + + return $this->render('ux_cropperjs/crop.html.twig', [ + 'form' => $form, + 'croppedImageData' => $croppedImageData, + ]); + } + + #[Route('/crop-with-aspect-ratio', name: 'crop_with_aspect_ratio')] + public function cropWithAspectRatio(CropperInterface $cropper, Request $request): Response + { + $crop = $cropper->createCrop($this->publicDir . '/images/example.jpg'); + $crop->setCroppedMaxSize(1920, 1080); + + $form = $this->createFormBuilder(['crop' => $crop]) + ->add('crop', CropperType::class, [ + 'public_url' => '/images/example.jpg', + 'cropper_options' => [ + 'aspectRatio' => 16 / 9, + 'viewMode' => 1, + ], + ]) + ->getForm(); + + $form->handleRequest($request); + + $croppedImageData = null; + if ($form->isSubmitted() && $form->isValid()) { + // Get the cropped image as base64 + $croppedImageData = base64_encode($crop->getCroppedImage()); + } + + return $this->render('ux_cropperjs/crop.html.twig', [ + 'form' => $form, + 'croppedImageData' => $croppedImageData, ]); } } 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 new file mode 100644 index 00000000000..9a12f7a6b7c --- /dev/null +++ b/apps/e2e/src/Controller/TestAutocompleteController.php @@ -0,0 +1,63 @@ +render('test/autocomplete_dynamic_form.html.twig'); + } + + #[Route('/movie', name: 'movie')] + public function movie(Request $request): JsonResponse + { + $query = $request->query->get('query', ''); + + $movies = [ + ['value' => 'movie_1', 'text' => 'The Matrix (1999)', 'title' => 'movie Movie #1'], + ['value' => 'movie_2', 'text' => 'Inception (2010)', 'title' => 'movie Movie #2'], + ['value' => 'movie_3', 'text' => 'The Dark Knight (2008)', 'title' => 'movie Movie #3'], + ['value' => 'movie_4', 'text' => 'Interstellar (2014)', 'title' => 'movie Movie #4'], + ['value' => 'movie_5', 'text' => 'Pulp Fiction (1994)', 'title' => 'movie Movie #5'], + ]; + + $results = array_filter($movies, function ($movie) use ($query) { + return '' === $query || false !== stripos($movie['text'], $query); + }); + + return $this->json([ + 'results' => array_values($results), + ]); + } + + #[Route('/videogame', name: 'videogame')] + public function videogame(Request $request): JsonResponse + { + $query = $request->query->get('query', ''); + + $games = [ + ['value' => 'videogame_1', 'text' => 'Halo: Combat Evolved (2001)', 'title' => 'videogame Game #1'], + ['value' => 'videogame_2', 'text' => 'The Legend of Zelda (1986)', 'title' => 'videogame Game #2'], + ['value' => 'videogame_3', 'text' => 'Half-Life 2 (2004)', 'title' => 'videogame Game #3'], + ['value' => 'videogame_4', 'text' => 'Portal (2007)', 'title' => 'videogame Game #4'], + ['value' => 'videogame_5', 'text' => 'Mass Effect 2 (2010)', 'title' => 'videogame Game #5'], + ]; + + $results = array_filter($games, function ($game) use ($query) { + return '' === $query || false !== stripos($game['text'], $query); + }); + + return $this->json([ + 'results' => array_values($results), + ]); + } +} 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..9b3ec0b5db6 100644 --- a/apps/e2e/src/Controller/TurboController.php +++ b/apps/e2e/src/Controller/TurboController.php @@ -3,17 +3,57 @@ namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\Routing\Attribute\Route; +use Symfony\UX\Turbo\TurboBundle; -#[Route('/ux-turbo')] +#[Route('/ux-turbo', name: 'app_ux_turbo_')] final class TurboController extends AbstractController { - #[Route('/')] - public function index(): Response + + #[Route('/drive', name: 'drive')] + public function drive( + #[MapQueryParameter] int $page = 1, + ): Response { - return $this->render('ux_turbo/index.html.twig', [ - 'controller_name' => 'TurboController', + if ($page === 2) { + return $this->render('ux_turbo/drive_page_2.html.twig', [ + 'current_time' => (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339_EXTENDED), + ]); + } + + return $this->render('ux_turbo/drive.html.twig', [ + 'current_time' => (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339_EXTENDED), ]); } + + + #[Route('/frame', name: 'frame')] + public function frame(): Response + { + return $this->render('ux_turbo/frame.html.twig'); + } + + #[Route('/frame-content', name: 'frame_content')] + public function frameContent(): Response + { + return $this->render('ux_turbo/frame_content.html.twig'); + } + + #[Route('/stream', name: 'stream')] + public function streamAction(Request $request): Response + { + if ($request->isMethod('POST')) { + if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + return $this->render('ux_turbo/stream_response.html.twig'); + } + + return $this->redirectToRoute('app_ux_turbo_stream'); + } + + return $this->render('ux_turbo/stream.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/Entity/Fruit.php b/apps/e2e/src/Entity/Fruit.php new file mode 100644 index 00000000000..3ad9f798b9a --- /dev/null +++ b/apps/e2e/src/Entity/Fruit.php @@ -0,0 +1,36 @@ +id = $id; + $fruit->name = $name; + + return $fruit; + } + + public function getId(): string + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } +} 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/Factory/FruitFactory.php b/apps/e2e/src/Factory/FruitFactory.php new file mode 100644 index 00000000000..b30b525f577 --- /dev/null +++ b/apps/e2e/src/Factory/FruitFactory.php @@ -0,0 +1,36 @@ + + */ +final class FruitFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return Fruit::class; + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories + */ + protected function defaults(): array|callable + { + return []; + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization + */ + protected function initialize(): static + { + return $this + ->instantiateWith(Instantiator::namedConstructor('create')) + ; + } +} diff --git a/apps/e2e/src/Form/FruitAutocompleteField.php b/apps/e2e/src/Form/FruitAutocompleteField.php new file mode 100644 index 00000000000..33d0d1776d5 --- /dev/null +++ b/apps/e2e/src/Form/FruitAutocompleteField.php @@ -0,0 +1,28 @@ +setDefaults([ + 'class' => Fruit::class, + 'placeholder' => 'Choose a Fruit', + 'choice_value' => static fn (?Fruit $fruit) => $fruit?->getId(), + 'choice_label' => static fn (Fruit $fruit) => $fruit->getName(), + ]); + } + + public function getParent(): string + { + return BaseEntityAutocompleteType::class; + } +} diff --git a/apps/e2e/src/Form/Model/ProductionDto.php b/apps/e2e/src/Form/Model/ProductionDto.php new file mode 100644 index 00000000000..8c6f2c028d2 --- /dev/null +++ b/apps/e2e/src/Form/Model/ProductionDto.php @@ -0,0 +1,14 @@ +setDefaults([ + 'autocomplete' => true, + 'autocomplete_url' => $this->urlGenerator->generate('app_test_autocomplete_movie'), + 'tom_select_options' => [ + 'maxOptions' => null, + ], + 'attr' => [ + 'data-test-id' => 'movie-autocomplete', + 'data-controller' => 'movie-autocomplete', + ], + ]); + } + + public function getParent(): string + { + return TextType::class; + } +} diff --git a/apps/e2e/src/Form/Type/ProductionType.php b/apps/e2e/src/Form/Type/ProductionType.php new file mode 100644 index 00000000000..137148e56df --- /dev/null +++ b/apps/e2e/src/Form/Type/ProductionType.php @@ -0,0 +1,66 @@ +add('type', ChoiceType::class, [ + 'choices' => [ + 'Movie' => 'movie', + 'Videogame' => 'videogame', + ], + 'placeholder' => 'Select a type', + 'attr' => [ + 'data-test-id' => 'production-type', + ], + ]) + ->addDependent('movieSearch', ['type'], function (DependentField $field, ?string $type) { + if ('movie' !== $type) { + return; + } + + $field->add(MovieAutocompleteType::class, [ + 'label' => 'Search Movies', + 'required' => false, + ]); + }) + ->addDependent('videogameSearch', ['type'], function (DependentField $field, ?string $type) { + if ('videogame' !== $type) { + return; + } + + $field->add(VideogameAutocompleteType::class, [ + 'label' => 'Search Videogames', + 'required' => false, + ]); + }) + ->add('title', TextType::class, [ + 'required' => false, + 'attr' => [ + 'data-test-id' => 'production-title', + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ProductionDto::class, + ]); + } +} diff --git a/apps/e2e/src/Form/Type/VideogameAutocompleteType.php b/apps/e2e/src/Form/Type/VideogameAutocompleteType.php new file mode 100644 index 00000000000..dcd7abb2f4b --- /dev/null +++ b/apps/e2e/src/Form/Type/VideogameAutocompleteType.php @@ -0,0 +1,36 @@ +setDefaults([ + 'autocomplete' => true, + 'autocomplete_url' => $this->urlGenerator->generate('app_test_autocomplete_videogame'), + 'tom_select_options' => [ + 'maxOptions' => null, + ], + 'attr' => [ + 'data-test-id' => 'videogame-autocomplete', + 'data-controller' => 'videogame-autocomplete', + ], + ]); + } + + public function getParent(): string + { + return TextType::class; + } +} 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 51f3e2a3f9b..61379893b09 100644 --- a/apps/e2e/src/Repository/ExampleRepository.php +++ b/apps/e2e/src/Repository/ExampleRepository.php @@ -21,37 +21,60 @@ class ExampleRepository */ private array $examples; - public function __construct() { + public function __construct() + { $this->examples = [ - 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::ChartJs, 'Line chart without options', 'A basic line chart displaying monthly data without additional options.', 'app_ux_chartjs_without_options'), + new Example(UxPackage::ChartJs, 'Line chart with options', 'A line chart with custom options (showLines: false) that displays data points without connecting lines.', 'app_ux_chartjs_with_options'), + new Example(UxPackage::ChartJs, 'Pie chart', 'A pie chart displaying data distribution across different categories.', 'app_ux_chartjs_pie'), + new Example(UxPackage::ChartJs, 'Pie chart with options', 'A pie chart with custom options to control the appearance and behavior.', 'app_ux_chartjs_pie_with_options'), + new Example(UxPackage::Cropperjs, 'Image cropper', 'Crop an image with Cropper.js using default options.', 'app_ux_cropperjs_crop'), + new Example(UxPackage::Cropperjs, 'Image cropper with aspect ratio', 'Crop an image with a fixed 16:9 aspect ratio constraint.', 'app_ux_cropperjs_crop_with_aspect_ratio'), + 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::Turbo, 'Turbo Drive navigation', 'Navigate between pages without full page reload using Turbo Drive.', 'app_ux_turbo_drive'), + new Example(UxPackage::Turbo, 'Turbo Frame', 'A scoped section that navigates independently from the rest of the page.', 'app_ux_turbo_frame'), + new Example(UxPackage::Turbo, 'Turbo Stream after form submit', 'Update page content with Turbo Streams after a form submission.', 'app_ux_turbo_stream'), + 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'), ]; } @@ -63,21 +86,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 new file mode 100644 index 00000000000..6d7bf4f12f8 --- /dev/null +++ b/apps/e2e/src/Repository/FruitRepository.php @@ -0,0 +1,32 @@ + + */ +class FruitRepository extends ServiceEntityRepository +{ + 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/Story/AppStory.php b/apps/e2e/src/Story/AppStory.php new file mode 100644 index 00000000000..07b1bc46459 --- /dev/null +++ b/apps/e2e/src/Story/AppStory.php @@ -0,0 +1,35 @@ + 'apple', 'name' => 'Apple'], + ['id' => 'banana', 'name' => 'Banana'], + ['id' => 'cherry', 'name' => 'Cherry'], + ['id' => 'coconut', 'name' => 'Coconut'], + ['id' => 'grape', 'name' => 'Grape'], + ['id' => 'kiwi', 'name' => 'Kiwi'], + ['id' => 'lemon', 'name' => 'Lemon'], + ['id' => 'mango', 'name' => 'Mango'], + ['id' => 'orange', 'name' => 'Orange'], + ['id' => 'papaya', 'name' => 'Papaya'], + ['id' => 'peach', 'name' => 'Peach'], + ['id' => 'pineapple', 'name' => 'Pineapple'], + ['id' => 'pear', 'name' => 'Pear'], + ['id' => 'pomegranate', 'name' => 'Pomegranate'], + ['id' => 'pomelo', 'name' => 'Pomelo'], + ['id' => 'raspberry', 'name' => 'Raspberry'], + ['id' => 'strawberry', 'name' => 'Strawberry'], + ['id' => 'watermelon', 'name' => 'Watermelon'], + ]); + } +} 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/Components/ProductionForm.php b/apps/e2e/src/Twig/Components/ProductionForm.php new file mode 100644 index 00000000000..3c67094744f --- /dev/null +++ b/apps/e2e/src/Twig/Components/ProductionForm.php @@ -0,0 +1,43 @@ +createForm(ProductionType::class, $this->initialFormData ?? new ProductionDto()); + } + + #[LiveListener('movie-selected')] + public function onMovieSelected(#[LiveArg] string $title): void + { + $this->formValues['title'] = $title; + } + + #[LiveListener('videogame-selected')] + public function onVideogameSelected(#[LiveArg] string $title): void + { + $this->formValues['title'] = $title; + } +} 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/symfony.lock b/apps/e2e/symfony.lock index fc7be91565d..e84905f5a25 100644 --- a/apps/e2e/symfony.lock +++ b/apps/e2e/symfony.lock @@ -452,5 +452,18 @@ }, "twig/extra-bundle": { "version": "v3.21.0" + }, + "zenstruck/foundry": { + "version": "2.8", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.7", + "ref": "7fc98f546dfeaa83cc2110634f8ff078d070b965" + }, + "files": [ + "config/packages/zenstruck_foundry.yaml", + "src/Story/AppStory.php" + ] } } 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 new file mode 100644 index 00000000000..fb37b60d318 --- /dev/null +++ b/apps/e2e/templates/components/ProductionForm.html.twig @@ -0,0 +1,34 @@ +{% form_theme form 'bootstrap_5_layout.html.twig' %} +
+ {{ form_start(form) }} +
+ {{ form_label(form.type) }} + {{ form_widget(form.type) }} + {{ form_errors(form.type) }} +
+ + {% if form.movieSearch is defined %} +
+ {{ form_label(form.movieSearch) }} + {{ form_widget(form.movieSearch) }} + {{ form_errors(form.movieSearch) }} +
+ {% endif %} + + {% if form.videogameSearch is defined %} +
+ {{ form_label(form.videogameSearch) }} + {{ form_widget(form.videogameSearch) }} + {{ form_errors(form.videogameSearch) }} +
+ {% endif %} + +
+ {{ form_label(form.title) }} + {{ form_widget(form.title) }} + {{ form_errors(form.title) }} +
+ + + {{ form_end(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 new file mode 100644 index 00000000000..f262bd6b044 --- /dev/null +++ b/apps/e2e/templates/test/autocomplete_dynamic_form.html.twig @@ -0,0 +1,12 @@ +{% extends 'base.html.twig' %} + +{% block title %}Autocomplete Dynamic Form Test{% endblock %} + +{% block main %} +
+

Autocomplete with Dynamic Forms

+

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

+ + +
+{% endblock %} diff --git a/apps/e2e/templates/ux_autocomplete/custom_controller.html.twig b/apps/e2e/templates/ux_autocomplete/custom_controller.html.twig new file mode 100644 index 00000000000..7793a3d8d94 --- /dev/null +++ b/apps/e2e/templates/ux_autocomplete/custom_controller.html.twig @@ -0,0 +1,5 @@ +{% extends 'example.html.twig' %} + +{% block example %} + {{ component('ProductionForm') }} +{% endblock %} diff --git a/apps/e2e/templates/ux_autocomplete/index.html.twig b/apps/e2e/templates/ux_autocomplete/index.html.twig deleted file mode 100644 index 78c01e96007..00000000000 --- a/apps/e2e/templates/ux_autocomplete/index.html.twig +++ /dev/null @@ -1,3 +0,0 @@ -{% extends 'example.html.twig' %} - -{% block example %}{% endblock %} diff --git a/apps/e2e/templates/ux_autocomplete/with_ajax.html.twig b/apps/e2e/templates/ux_autocomplete/with_ajax.html.twig new file mode 100644 index 00000000000..f99295f8d25 --- /dev/null +++ b/apps/e2e/templates/ux_autocomplete/with_ajax.html.twig @@ -0,0 +1,7 @@ +{% extends 'example.html.twig' %} + +{% block example %} + {{ form_start(form) }} + {{ form_row(form.favorite_fruit) }} + {{ form_end(form) }} +{% endblock %} diff --git a/apps/e2e/templates/ux_autocomplete/without_ajax.html.twig b/apps/e2e/templates/ux_autocomplete/without_ajax.html.twig new file mode 100644 index 00000000000..f99295f8d25 --- /dev/null +++ b/apps/e2e/templates/ux_autocomplete/without_ajax.html.twig @@ -0,0 +1,7 @@ +{% extends 'example.html.twig' %} + +{% block example %} + {{ form_start(form) }} + {{ form_row(form.favorite_fruit) }} + {{ form_end(form) }} +{% endblock %} diff --git a/apps/e2e/templates/ux_chartjs/index.html.twig b/apps/e2e/templates/ux_chartjs/index.html.twig index 78c01e96007..40c8655d3f7 100644 --- a/apps/e2e/templates/ux_chartjs/index.html.twig +++ b/apps/e2e/templates/ux_chartjs/index.html.twig @@ -1,3 +1,14 @@ {% extends 'example.html.twig' %} -{% block example %}{% endblock %} +{% block example %} +
+ {{ render_chart(chart, {'id': 'test-chart'}) }} +
+ + +{% endblock %} diff --git a/apps/e2e/templates/ux_cropperjs/crop.html.twig b/apps/e2e/templates/ux_cropperjs/crop.html.twig new file mode 100644 index 00000000000..057c6a176f3 --- /dev/null +++ b/apps/e2e/templates/ux_cropperjs/crop.html.twig @@ -0,0 +1,26 @@ +{% extends 'example.html.twig' %} + +{% block example %} +
+
+ {% if croppedImageData %} +
+
Image cropped successfully!
+

Here is your cropped image:

+ Cropped image +
+ {% endif %} + + {{ form_start(form, { attr: { 'data-turbo': 'false' } }) }} + {{ form_row(form.crop) }} +
+ +
+ {{ form_end(form) }} +
+
+{% endblock %} diff --git a/apps/e2e/templates/ux_cropperjs/index.html.twig b/apps/e2e/templates/ux_cropperjs/index.html.twig deleted file mode 100644 index 78c01e96007..00000000000 --- a/apps/e2e/templates/ux_cropperjs/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/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/apps/e2e/templates/ux_translator/basic.html.twig b/apps/e2e/templates/ux_translator/basic.html.twig index 476d2f335b6..c07aefc9572 100644 --- a/apps/e2e/templates/ux_translator/basic.html.twig +++ b/apps/e2e/templates/ux_translator/basic.html.twig @@ -11,15 +11,14 @@ {% block javascripts %} {{ parent() }}