diff --git a/.github/build-packages.php b/.github/build-packages.php new file mode 100644 index 00000000000..87f8e41f23b --- /dev/null +++ b/.github/build-packages.php @@ -0,0 +1,21 @@ +repositories[] = [ + 'type' => 'path', + 'url' => '../TwigComponent', +]; + +$json = preg_replace('/\n "repositories": \[\n.*?\n \],/s', '', $json); +$json = rtrim(json_encode(['repositories' => $package->repositories], $flags), "\n}").','.substr($json, 1); +$json = preg_replace('/"symfony\/ux-twig-component": "(\^[\d]+\.[\d]+)"/s', '"symfony/ux-twig-component": "@dev"', $json); +file_put_contents($dir.'/composer.json', $json); + diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4299f6d8b6e..99b0b355467 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -54,6 +54,25 @@ jobs: cd src/LazyImage composer update --prefer-lowest --prefer-dist --no-interaction --no-ansi --no-progress php vendor/bin/simple-phpunit + - name: TwigComponent + run: | + cd src/TwigComponent + composer update --prefer-lowest --prefer-dist --no-interaction --no-ansi --no-progress + php vendor/bin/simple-phpunit + + tests-php-low-deps-74: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + - name: LiveComponent + run: | + cd src/LiveComponent + php ../../.github/build-packages.php + composer update --prefer-lowest --prefer-dist --no-interaction --no-ansi --no-progress + php vendor/bin/simple-phpunit tests-php-high-deps: runs-on: ubuntu-latest @@ -86,6 +105,19 @@ jobs: composer config platform.php 7.4.99 composer update --prefer-dist --no-interaction --no-ansi --no-progress php vendor/bin/simple-phpunit + - name: TwigComponent + run: | + cd src/TwigComponent + composer config platform.php 7.4.99 + composer update --prefer-dist --no-interaction --no-ansi --no-progress + php vendor/bin/simple-phpunit + - name: LiveComponent + run: | + cd src/LiveComponent + php ../../.github/build-packages.php + composer config platform.php 7.4.99 + composer update --prefer-dist --no-interaction --no-ansi --no-progress + php vendor/bin/simple-phpunit tests-js: runs-on: ubuntu-latest diff --git a/README.md b/README.md index a0367236b7a..0f3c9ec3ba7 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,10 @@ integrating it into [Webpack Encore](https://github.com/symfony/webpack-encore). Improve image loading performances through lazy-loading and data-uri thumbnails - [UX Swup](https://github.com/symfony/ux-swup): [Swup](https://swup.js.org/) page transition library integration for Symfony +- [Twig Component](https://github.com/symfony/ux-twig-component): + A system to build reusable "components" with Twig +- [Live Component](https://github.com/symfony/ux-live-component): + Gives Twig Components a URL and a JavaScript library to automatically re-render via Ajax as your user interacts with it ## Stimulus Tools around the World diff --git a/package.json b/package.json index cd3605648e9..71cfa8b9ba6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "private": true, "workspaces": [ - "src/**/Resources/assets" + "src/**/Resources/assets", + "src/**/assets" ], "scripts": { "build": "yarn workspaces run build", diff --git a/src/LiveComponent/.gitattributes b/src/LiveComponent/.gitattributes new file mode 100644 index 00000000000..91aea0167b2 --- /dev/null +++ b/src/LiveComponent/.gitattributes @@ -0,0 +1,2 @@ +/.github export-ignore +/tests export-ignore diff --git a/src/LiveComponent/.gitignore b/src/LiveComponent/.gitignore new file mode 100644 index 00000000000..854217846fe --- /dev/null +++ b/src/LiveComponent/.gitignore @@ -0,0 +1,5 @@ +/composer.lock +/phpunit.xml +/vendor/ +/var/ +/.phpunit.result.cache diff --git a/src/LiveComponent/LICENSE b/src/LiveComponent/LICENSE new file mode 100644 index 00000000000..45c069b323b --- /dev/null +++ b/src/LiveComponent/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/LiveComponent/README.md b/src/LiveComponent/README.md new file mode 100644 index 00000000000..d23e887376f --- /dev/null +++ b/src/LiveComponent/README.md @@ -0,0 +1,1160 @@ +# Live Components + +**EXPERIMENTAL** This component is currently experimental and is +likely to change, or even change drastically. + +Live components work with the [TwigComponent](../TwigComponent) +library to give you the power to automatically update your +Twig components on the frontend as the user interacts with them. +Inspired by [Livewire](https://laravel-livewire.com/) and +[Phoenix LiveView](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html). + +A real-time product search component might look like this: + +```php +// src/Components/ProductSearchComponent.php +namespace App\Components; + +use Symfony\UX\LiveComponent\LiveComponentInterface; + +class ProductSearchComponent implements LiveComponentInterface +{ + public string $query = ''; + + private ProductRepository $productRepository; + + public function __construct(ProductRepository $productRepository) + { + $this->productRepository = $productRepository; + } + + public function getProducts(): array + { + // example method that returns an array of Products + return $this->productRepository->search($this->query); + } + + public static function getComponentName(): string + { + return 'product_search'; + } +} +``` + +```twig +{# templates/components/product_search.html.twig #} +
+ + + +
+``` + +As a user types into the box, the component will automatically +re-render and show the new results! + +## Installation + +Let's get started! Install the library with: + +``` +composer require symfony/ux-live-component +``` + +This comes with an embedded JavaScript Stimulus controller. Unlike +other Symfony UX packages, this needs to be enabled manually +in your `config/bootstrap.js` file: + +```js +// config/bootstrap.js +import LiveController from '@symfony/ux-live-component'; +import '@symfony/ux-live-component/styles/live.css'; +// ... + +app.register('live', LiveController); +``` + +Finally, reinstall your Node dependencies and restart Encore: + +``` +yarn install --force +yarn encore dev +``` + +That's it! We're ready! + +## Making your Component "Live" + +If you haven't already, check out the [Twig Component](../TwigComponent) +documentation to get the basics of Twig components. + +Suppose you've already built a basic Twig component: + +```php +// src/Components/RandomNumberComponent.php +namespace App\Components; + +use Symfony\UX\TwigComponent\ComponentInterface; + +class RandomNumberComponent implements ComponentInterface +{ + public function getRandomNumber(): string + { + return rand(0, 1000); + } + + public static function getComponentName(): string + { + return 'random_number'; + } +} +``` + +```twig +{# templates/components/random_number.html.twig #} +
+ {{ this.randomNumber }} +
+``` + +To transform this into a "live" component (i.e. one that +can be re-rendered live on the frontend), change your +component's interface to `LiveComponentInterface`: + +```diff +// src/Components/RandomNumberComponent.php + ++use Symfony\UX\LiveComponent\LiveComponentInterface; + +-class RandomNumberComponent implements ComponentInterface ++class RandomNumberComponent implements LiveComponentInterface +{ +} +``` + +Then, in the template, make sure there is _one_ HTML element around +your entire component and use the `{{ init_live_component() }}` function +to initialize the Stimulus controller: + +```diff +-
++
+ {{ this.randomNumber }} +
+``` + +Your component is now a live component... except that we haven't added +anything that would cause the component to update. Let's start simple, +by adding a button that - when clicked - will re-render the component +and give the user a new random number: + +```twig +
+ {{ this.randomNumber }} + + +
+``` + +That's it! When you click the button, an Ajax call will be made to +get a fresh copy of our component. That HTML will replace the current +HTML. In other words, you just generated a new random number! That's +cool, but let's keep going because... things get cooler. + +## LiveProps: Stateful Component Properties + +Let's make our component more flexible by adding `$min` and `$max` properties: + +```php +// src/Components/RandomNumberComponent.php +namespace App\Components; + +// ... +use Symfony\UX\LiveComponent\Attribute\LiveProp; + +class RandomNumberComponent implements LiveComponentInterface +{ + /** @LiveProp */ + public int $min = 0; + /** @LiveProp */ + public int $max = 1000; + + public function getRandomNumber(): string + { + return rand($this->min, $this->max); + } + + // ... +} +``` + +With this change, we can control the `$min` and `$max` properties +when rendering the component: + +``` +{{ component('random_number', { min: 5, max: 500 }) }} +``` + +But what's up with those `@LiveProp` annotations? A property with +the `@LiveProp` annotation (or `LiveProp` PHP 8 attribute) becomes +a "stateful" property for this component. In other words, each time +we click the "Generate a new number!" button, when the component +re-renders, it will _remember_ the original values for the `$min` and +`$max` properties and generate a random number between 5 and 500. +If you forgot to add `@LiveProp`, when the component re-rendered, +those two values would _not_ be set on the object. + +In short: LiveProps are "stateful properties": they will always +be set when rendering. Most properties will be LiveProps, with +common exceptions being properties that hold services (these don't +need to be stateful because they will be autowired each time before +the component is rendered) and +[properties used for computed properties](../TwigComponent/README.md#computed-properties). + +## data-action="live#update": Re-rendering on LiveProp Change + +Could we allow the user to _choose_ the `$min` and `$max` values +and automatically re-render the component when they do? Definitely! +And _that_ is where live components really shine. + +Let's add two inputs to our template: + +```twig +{# templates/components/random_number.html.twig #} +
+ + + + + Generating a number between {{ this.min }} and {{ this.max }} + {{ this.randomNumber }} +
+``` + +Notice the `data-action="live#update"` on each `input`. When the +user types, live components reads the `data-model` attribute (e.g. `min`) +and re-renders the component using the _new_ value for that field! Yes, +as you type in a box, the component automatically updates to reflect the +new number! + +Well, actually, we're missing one step. By default, a `LiveProp` is +"read only". For security purposes, a user cannot change the value of +a `LiveProp` and re-render the component unless you allow it with +the `writable=true` option: + +```diff +// src/Components/RandomNumberComponent.php +// ... + +class RandomNumberComponent implements LiveComponentInterface +{ +- /** @LiveProp() */ ++ /** @LiveProp(writable=true) */ + public int $min = 0; +- /** @LiveProp() */ ++ /** @LiveProp(writable=true) */ + public int $max = 1000; + + // ... +} +``` + +Now it works: as you type into the `min` or `max` boxes, the component +will re-render with a new random number between that range! + +### Debouncing + +If the user types 5 characters really quickly into an `input`, we +don't want to send 5 Ajax requests. Fortunately, the `live#update` +method has built-in debouncing: it waits for a 150ms pause before +sending an Ajax request to re-render. This is built in, so you +don't need to think about it. + +### Lazy Updating on "blur" or "change" of a Field + +Sometimes, you might want a field to re-render only after the user +has changed an input _and_ moved to another field. Browsers dispatch +a `change` event in this situation. To re-render when this event +happens, add it to the `data-action` call: + +```diff + +``` + +The `data-action="change->live#update"` syntax is standard Stimulus +syntax, which says: + +> When the "change" event occurs, call the `update` method on the +> `live` controller. + +### Deferring a Re-Render Until Later + +Other times, you might want to update the internal value of a property, +but wait until later to re-render the component (e.g. until a button +is clicked). To do that, use the `updateDefer` method: + +```diff + +``` + +Now, as you type, the `max` "model" will be updated in JavaScript, but +it won't, yet, make an Ajax call to re-render the component. Whenever +the next re-render _does_ happen, the updated `max` value will be used. + +### Using name="" instead of data-model + +Instead of communicating the property name of a field via `data-model`, +you can communicate it via the standard `name` property. The following +code works identically to the previous example: + +```diff +
+ + + // ... +
+``` + +## Loading States + +Often, you'll want to show (or hide) an element while a component is +re-rendering or an [action](#actions) is processing. For example: + +```twig + +Loading + + +Loading +``` + +Or, to _hide_ an element while the component is loading: + +```twig + +Saved! +``` + +### Adding and Removing Classes or Attributes + +Instead of hiding or showing an entire element, you could +add or remove a class: + +```twig + +
...
+ + +
...
+ + +
...
+``` + +Sometimes you may want to add or remove an attribute when loading. +That can be accomplished with `addAttribute` or `removeAttribute`: + +```twig + +
...
+``` + +You can also combine any number of directives by separating them +with a space: + +```twig +
...
+``` + +Finally, you can add the `delay` modifier to not trigger the loading +changes until loading has taken longer than a certain amount of time: + +```twig + +
...
+ + +
Loading
+ + +
Loading
+``` + +## Actions + +You can also trigger actions on your component. Let's pretend we +want to add a "Reset Min/Max" button to our "random number" +component that, when clicked, sets the min/max numbers back +to a default value. + +First, add a method with a `LiveAction` annotation (or PHP 8 attribute) +above it that does the work: + +```php +// src/Components/RandomNumberComponent.php +namespace App\Components; + +// ... +use Symfony\UX\LiveComponent\Attribute\LiveAction; + +class RandomNumberComponent implements LiveComponentInterface +{ + // ... + + /** + * @LiveAction + */ + public function resetMinMax() + { + $this->min = 0; + $this->max = 1000; + } + + // ... +} +``` + +To call this, add `data-action="live#action"` and `data-action-name` +to an element (e.g. a button or form): + +```twig + +``` + +Done! When the user clicks this button, a POST request will be sent +that will trigger the `resetMinMax()` method! After calling that method, +the component will re-render like normal, using the new `$min` and `$max` +properties! + +You can also add several "modifiers" to the action: + +```twig +
+ +
+``` + +The `prevent` modifier would prevent the form from submitting +(`event.preventDefault()`). The `debounce(300)` modifier will +add 300ms of "debouncing" before the action is executed. In +other words, if you click really fast 5 times, only one Ajax +request will be made! + +#### Actions & Services + +One really neat thing about component actions is that they are +_real_ Symfony controllers. Internally, they are processed +identically to a normal controller method that you would create +with a route. + +This means that, for example, you can use action autowiring: + +```php +// src/Components/RandomNumberComponent.php +namespace App\Components; + +// ... +use Psr\Log\LoggerInterface; + +class RandomNumberComponent implements LiveComponentInterface +{ + // ... + + /** + * @LiveAction + */ + public function resetMinMax(LoggerInterface $logger) + { + $this->min = 0; + $this->max = 1000; + $logger->debug('The min/max were reset!'); + } + + // ... +} +``` + +### Actions and CSRF Protection + +When you trigger an action, a POST request is sent that contains +a `X-CSRF-TOKEN` header. This header is automatically populated +and violated. In other words... you get CSRF protection without +any work. + +Your only job is to make sure that the CSRF component is installed: + +``` +composer require symfony/security-csrf +``` + +### Actions, Redirecting and AbstractController + +Sometimes, you may want to redirect after an action is executed +(e.g. your action saves a form and then you want to redirect to +another page). You can do that by returning a `RedirectResponse` +from your action: + +```php +// src/Components/RandomNumberComponent.php +namespace App\Components; + +// ... +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class RandomNumberComponent extends AbstractController implements LiveComponentInterface +{ + // ... + + /** + * @LiveAction + */ + public function resetMinMax() + { + // ... + + $this->addFlash('success', 'Min/Max have been reset!'); + + return $this->redirectToRoute('app_random_number'); + } + + // ... +} +``` + +You probably noticed one interesting trick: to make redirecting easier, +the component now extends `AbstractController`! That is totally allowed, +and gives you access to all of your normal controller shortcuts. We +even added a flash message! + +## Forms + +A component can also help render a [Symfony form](https://symfony.com/doc/current/forms.html), +either the entire form (useful for automatic validation as you type) or just +one or some fields (e.g. a markdown preview for a `textarea` or +[dependent form fields](https://symfony.com/doc/current/form/dynamic_form_modification.html#dynamic-generation-for-submitted-forms)). + +### Rendering an Entire Form in a Component + +Suppose you have a `PostType` form class that's bound to a `Post` entity +and you'd like to render this in a component so that you can get instant +validation as the user types: + +```php +namespace App\Form; + +use App\Entity\Post; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class PostType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('title') + ->add('slug') + ->add('content') + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Post::class, + ]); + } +} +``` + +Before you start thinking about the component, make sure that you +have your controller set up so you can handle the form submit. There's +nothing special about this controller: it's written however you normally +write your form controller logic: + +```php +namespace App\Controller; + +use App\Entity\Post; +use App\Form\PostType; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; + +class PostController extends AbstractController +{ + /** + * @Route("/admin/post/{id}/edit", name="app_post_edit") + */ + public function edit(Request $request, Post $post): Response + { + $form = $this->createForm(PostType::class, $post); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->getDoctrine()->getManager()->flush(); + + return $this->redirectToRoute('app_post_index'); + } + + // renderForm() is new in Symfony 5.3. + // Use render() and call $form->createView() if on a lower version + return $this->renderForm('post/edit.html.twig', [ + 'post' => $post, + 'form' => $form, + ]); + } +} +``` + +Great! In the template, instead of rendering the form, let's render +a `post_form` component that we will create next: + +```twig +{# templates/post/edit.html.twig #} + +{% extends 'base.html.twig' %} + +{% block body %} +

Edit Post

+ + {{ component('post_form', { + post: post, + form: form + }) }} +{% endblock %} +``` + +Ok: time to build that `post_form` component! The Live Components package +come with a special trait - `ComponentWithFormTrait` - to make it easy to +deal with forms: + +```php +namespace App\Twig\Components; + +use App\Entity\Post; +use App\Form\PostType; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormInterface; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\LiveComponentInterface; +use Symfony\UX\LiveComponent\ComponentWithFormTrait; + +class PostFormComponent extends AbstractController implements LiveComponentInterface +{ + use ComponentWithFormTrait; + + /** + * The initial data used to create the form. + * + * Needed so the same form can be re-created + * when the component is re-rendered via Ajax. + * + * The fieldName="" option is needed in this situation because + * the form renders fields with names like `name="post[title]"`. + * We set fieldName="" so that this live prop doesn't collide + * with that data. The value - initialFormData - could be anything. + * + * @LiveProp(fieldName="initialFormData") + */ + public ?Post $post = null; + + /** + * Used to re-create the PostType form for re-rendering. + */ + protected function instantiateForm(): FormInterface + { + // we can extend AbstractController to get the normal shortcuts + return $this->createForm(PostType::class, $this->post); + } + + public static function getComponentName(): string + { + return 'post_form'; + } +} +``` + +The trait forces you to create an `instantiateForm()` method, +which is used when the component is rendered via AJAX. Notice that, +in order to recreate the _same_ form, we pass in the `Post` object +and set it as a `LiveProp`. + +The template for this component will render the form, which is +available as `this.form` thanks to the trait: + +```twig +{# templates/components/post_form.html.twig #} +
+ {{ form_start(this.form) }} + {{ form_row(this.form.title) }} + {{ form_row(this.form.slug) }} + {{ form_row(this.form.content) }} + + + {{ form_end(this.form) }} +
+``` + +Mostly, this is a pretty boring template! It includes the normal +`init_live_component(this)` and then you render the form however you want. + +But the result is incredible! As you finish changing each field, the +component automatically re-renders - including showing any validation +errors for that field! Amazing! + +This is possible thanks to a few interesting pieces: + +- `data-action="change->live#update"`: instead of adding `data-action` + to _every_ field, you can place this on a parent element. Thanks to + this, as you change or type into fields (i.e. the `input` event), + the model for that field will update and the component will re-render. + +- The fields in our form do not have a `data-model=""` attribute. But + that's ok! When that is absent, the `name` attribute is used instead. + `ComponentWithFormTrait` has a modifiable `LiveProp` that captures + these and submits the form using them. That's right: each render time + the component re-renders, the form is _submitted_ using the values. + However, if a field has not been modified yet by the user, its + validation errors are cleared so that they aren't rendered. + +### Form Rendering Problems + +For the most part, rendering a form inside a component works beautifully. +But there are a few situations when your form may not behave how you +want. + +**A) Text Boxes Removing Trailing Spaces** + +If you're re-rendering a field on the `input` event (that's the default +event on a field, which is fired each time you type in a text box), then +if you type a "space" and pause for a moment, the space will disappear! + +This is because Symfony text fields "trim spaces" automatically. When +your component re-renders, the space will disappear... as the user is typing! +To fix this, either re-render on the `change` event (which fires after +the text box loses focus) or set the `trim` option of your field to +`false`: + +```php +public function buildForm(FormBuilderInterface $builder, array $options) +{ + $builder + // ... + ->add('content', TextareaType::class, [ + 'trim' => false, + ]) + ; +} +``` + +**B) `PasswordType` loses the password on re-render** + +If you're using the `PasswordType`, when the component re-renders, +the input will become blank! That's because, by default, the +`PasswordType` does not re-fill the `` after +a submit. + +To fix this, set the `always_empty` option to `false` in your form: + +```php +public function buildForm(FormBuilderInterface $builder, array $options) +{ + $builder + // ... + ->add('plainPassword', PasswordType::class, [ + 'always_empty' => false, + ]) + ; +} +``` + +### Submitting the Form via an action() + +Notice that, while we _could_ add a `save()` [component action](#actions) +that handles the form submit through the component, we've chosen not +to do that so far. The reason is simple: by creating a normal route & +controller that handles the submit, our form continues to work without +JavaScript. + +However, you _can_ do this if you'd like. In that case, you wouldn't +need any form logic in your controller: + +```php +/** + * @Route("/admin/post/{id}/edit", name="app_post_edit") + */ +public function edit(Post $post): Response +{ + return $this->render('post/edit.html.twig', [ + 'post' => $post, + ]); +} +``` + +And you wouldn't pass any `form` into the component: + +```twig +{# templates/post/edit.html.twig #} + +

Edit Post

+ +{{ component('post_form', { + post: post +}) }} +``` + +When you do _not_ pass a `form` into a component that uses `ComponentWithFormTrait`, +the form will be created for you automatically. Let's add the `save()` +action to the component: + +```php +// ... +use Doctrine\ORM\EntityManagerInterface; +use Symfony\UX\LiveComponent\Attribute\LiveAction; + +class PostFormComponent extends AbstractController implements LiveComponentInterface +{ + // ... + + /** + * @LiveAction() + */ + public function save(EntityManagerInterface $entityManager) + { + // shortcut to submit the form with form values + // if any validation fails, an exception is thrown automatically + // and the component will be re-rendered with the form errors + $this->submitForm(); + + /** @var Post $post */ + $post = $this->getFormInstance()->getData(); + $entityManager->persist($post); + $entityManager->flush(); + + $this->addFlash('success', 'Post saved!'); + + return $this->redirectToRoute('app_post_show', [ + 'id' => $this->post->getId(), + ]); + } +} +``` + +Finally, tell the `form` element to use this action: + +``` +{# templates/components/post_form.html.twig #} +{# ... #} + +{{ form_start(this.form, { + attr: { + 'data-action': 'live#action', + 'data-action-name': 'prevent|save' + } +}) }} +``` + +Now, when the form is submitted, it will execute the `save()` method +via Ajax. If the form fails validation, it will re-render with the +errors. And if it's successful, it will redirect. + +## Modifying Embedded Properties with the "exposed" Option + +If your component will render a form, you don't need to use +the Symfony form component. Let's build an `EditPostComponent` +without a form. This will need one `LiveProp`: the `Post` object +that is being edited: + +```php +namespace App\Twig\Components; + +use App\Entity\Post; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\LiveComponentInterface; + +class EditPostComponent implements LiveComponentInterface +{ + /** + * @LiveProp() + */ + public Post $post; + + public static function getComponentName(): string + { + return 'edit_post'; + } +} +``` + +In the template, let's render an HTML form _and_ a "preview" area +where the user can see, as they type, what the post will look like +(including rendered the `content` through a Markdown filter from the +`twig/markdown-extra` library): + +``` +
+ + + + +
+

{{ this.post.title }}

+ {{ this.post.content|markdown_to_html }} +
+
+``` + +This is pretty straightforward, except for one thing: the `data-model` +attributes aren't targeting properties on the component class itself, +they're targeting _embedded_ properties within the `$post` property. + +Out-of-the-box, modifying embedded properties is _not_ allowed. However, +you can enable it via the `exposed` option: + +```diff +// ... + +class EditPostComponent implements LiveComponentInterface +{ + /** +- * @LiveProp(exposed={}) ++ * @LiveProp(exposed={"title", "content"}) + */ + public Post $post; + + // ... +} +``` + +With this, both the `title` and the `content` properties of the +`$post` property _can_ be modified by the user. However, notice +that the `LiveProp` does _not_ have `modifiable=true`. This +means that while the `title` and `content` properties can be +changed, the `Post` object itself **cannot** be changed. In other +words, if the component was originally created with a Post +object with id=2, a bad user could _not_ make a request that +renders the component with id=3. Your component is protected from +someone changing to see the form for a different `Post` object, +unless you added `writable=true` to this property. + +### Validation (without a Form) + +**NOTE** If your component [contains a form](#forms), then validation +is built-in automatically. Follow those docs for more details. + +If you're building some sort of form _without_ using Symfony's form +component, you _can_ still validate your data. + +First use the `ValidatableComponentTrait` and add any constraints you need: + +```php +use App\Entity\User; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\LiveComponentInterface; +use Symfony\UX\LiveComponent\ValidatableComponentTrait; +use Symfony\Component\Validator\Constraints as Assert; + +class EditUserComponent implements LiveComponentInterface +{ + use ValidatableComponentTrait; + + /** + * @LiveProp(exposed={"email", "plainPassword"}) + * @Assert\Valid() + */ + public User $user; + + /** + * @LiveProp() + * @Assert\IsTrue() + */ + public bool $agreeToTerms = false; + + public static function getComponentName() : string + { + return 'edit_user'; + } +} +``` + +Be sure to add the `@Assert\IsValid` to any property where you want +the object on that property to also be validated. + +Tahnks to this setup, the component will now be automatically validated +on each render, but in a smart way: a property will only be validated +once its "model" has been updated on the frontend. The system keeps track +of which models have been updated (e.g. `data-action="live#update"`) +and only stores the errors for those fields on re-render. + +You can also trigger validation of your _entire_ object manually +in an action: + +```php +use Symfony\UX\LiveComponent\Attribute\LiveAction; + +class EditUserComponent implements LiveComponentInterface +{ + // ... + + /** + * @LiveAction() + */ + public function save() + { + // this will throw an exception if validation fails + $this->validate(); + + // perform save operations + } +} +``` + +If validation fails, an exception is thrown, but the component will be +re-rendered. In your template, render errors using the `getError()` +method: + +```twig +{% if this.getError('post.content') %} +
+ {{ this.getError('post.content').message }} +
+{% endif %} + +``` + +Once a component has been validated, the component will "rememeber" +that it has been validated. This means that, if you edit a field and +the component re-renders, it will be validated again. + +## Real Time Validation + +As soon as you enable validation, each field will automatically +be validated when its model is updated. For example, if you want +a single field to be validated "on change" (when you change the field +and then blur the field), update the model via the `change` event: + +```twig + +``` + +When the component re-renders, it will signal to the server that this +one field should be validated. Like with normal validation, once an +individual field has been validated, the component "remembers" that, +and re-validates it on each render. + +## Polling + +You can also use "polling" to continually refresh a component. On +the **top-level** element for your component, add `data-poll`: + +```diff +
+``` + +This will make a request every 2 seconds to re-render the component. You +can change this by adding a `delay()` modifier. When you do this, you need +to be specific that you want to call the `$render` method. To delay for +500ms: + +```twig +
+``` + +You can also trigger a specific "action" instead of a normal re-render: + +```twig +
+``` diff --git a/src/LiveComponent/assets/.gitignore b/src/LiveComponent/assets/.gitignore new file mode 100644 index 00000000000..2ccbe4656c6 --- /dev/null +++ b/src/LiveComponent/assets/.gitignore @@ -0,0 +1 @@ +/node_modules/ diff --git a/src/LiveComponent/assets/.npmignore b/src/LiveComponent/assets/.npmignore new file mode 100644 index 00000000000..caf56370230 --- /dev/null +++ b/src/LiveComponent/assets/.npmignore @@ -0,0 +1,6 @@ +.babelrc +.gitignore +yarn.lock +/.git +/node_modules +/test diff --git a/src/LiveComponent/assets/babel.config.json b/src/LiveComponent/assets/babel.config.json new file mode 100644 index 00000000000..2c6b75b5575 --- /dev/null +++ b/src/LiveComponent/assets/babel.config.json @@ -0,0 +1,18 @@ +{ + "presets": [ + [ + "@babel/env", + { + "targets": { + "node": "current", + "ie": "11" + }, + "useBuiltIns": "entry", + "corejs": "3.14.0" + } + ] + ], + "plugins": [ + "@babel/plugin-proposal-class-properties" + ] +} diff --git a/src/LiveComponent/assets/dist/directives_parser.js b/src/LiveComponent/assets/dist/directives_parser.js new file mode 100644 index 00000000000..90750f03167 --- /dev/null +++ b/src/LiveComponent/assets/dist/directives_parser.js @@ -0,0 +1,228 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.parseDirectives = parseDirectives; + +/** + * A modifier for a directive + * + * @typedef {Object} DirectiveModifier + * @property {string} name The name of the modifier (e.g. delay) + * @property {string|null} value The value of the single argument or null if no argument + */ + +/** + * A directive with action, args and modifiers. + * + * @typedef {Object} Directive + * @property {string} action The name of the action (e.g. addClass) + * @property {string[]} args An array of unnamed arguments passed to the action + * @property {Object} named An object of named arguments + * @property {DirectiveModifier[]} modifiers Any modifiers applied to the action + * @property {function} getString() + */ + +/** + * Parses strings like "addClass(foo) removeAttribute(bar)" + * into an array of directives, with this format: + * + * [ + * { action: 'addClass', args: ['foo'], named: {}, modifiers: [] }, + * { action: 'removeAttribute', args: ['bar'], named: {}, modifiers: [] } + * ] + * + * This also handles named arguments + * + * save(foo=bar, baz=bazzles) + * + * Which would return: + * [ + * { action: 'save', args: [], named: { foo: 'bar', baz: 'bazzles }, modifiers: [] } + * ] + * + * @param {string} content The value of the attribute + * @return {Directive[]} + */ +function parseDirectives(content) { + var directives = []; + + if (!content) { + return directives; + } + + var currentActionName = ''; + var currentArgumentName = ''; + var currentArgumentValue = ''; + var currentArguments = []; + var currentNamedArguments = {}; + var currentModifiers = []; + var state = 'action'; + + var getLastActionName = function getLastActionName() { + if (currentActionName) { + return currentActionName; + } + + if (directives.length === 0) { + throw new Error('Could not find any directives'); + } + + return directives[directives.length - 1].action; + }; + + var pushInstruction = function pushInstruction() { + directives.push({ + action: currentActionName, + args: currentArguments, + named: currentNamedArguments, + modifiers: currentModifiers, + getString: function getString() { + // TODO - make a string representation of JUST this directive + return content; + } + }); + currentActionName = ''; + currentArgumentName = ''; + currentArgumentValue = ''; + currentArguments = []; + currentNamedArguments = {}; + currentModifiers = []; + state = 'action'; + }; + + var pushArgument = function pushArgument() { + var mixedArgTypesError = function mixedArgTypesError() { + throw new Error("Normal and named arguments cannot be mixed inside \"".concat(currentActionName, "()\"")); + }; + + if (currentArgumentName) { + if (currentArguments.length > 0) { + mixedArgTypesError(); + } // argument names are also trimmed to avoid space after "," + // "foo=bar, baz=bazzles" + + + currentNamedArguments[currentArgumentName.trim()] = currentArgumentValue; + } else { + if (Object.keys(currentNamedArguments).length > 0) { + mixedArgTypesError(); + } // value is trimmed to avoid space after "," + // "foo, bar" + + + currentArguments.push(currentArgumentValue.trim()); + } + + currentArgumentName = ''; + currentArgumentValue = ''; + }; + + var pushModifier = function pushModifier() { + if (currentArguments.length > 1) { + throw new Error("The modifier \"".concat(currentActionName, "()\" does not support multiple arguments.")); + } + + if (Object.keys(currentNamedArguments).length > 0) { + throw new Error("The modifier \"".concat(currentActionName, "()\" does not support named arguments.")); + } + + currentModifiers.push({ + name: currentActionName, + value: currentArguments.length > 0 ? currentArguments[0] : null + }); + currentActionName = ''; + currentArgumentName = ''; + currentArguments = []; + state = 'action'; + }; + + for (var i = 0; i < content.length; i++) { + var char = content[i]; + + switch (state) { + case 'action': + if (char === '(') { + state = 'arguments'; + break; + } + + if (char === ' ') { + // this is the end of the action and it has no arguments + // if the action had args(), it was already recorded + if (currentActionName) { + pushInstruction(); + } + + break; + } + + if (char === '|') { + // ah, this was a modifier (with no arguments) + pushModifier(); + break; + } // we're expecting more characters for an action name + + + currentActionName += char; + break; + + case 'arguments': + if (char === ')') { + // end of the arguments for a modifier or the action + pushArgument(); + state = 'after_arguments'; + break; + } + + if (char === ',') { + // end of current argument + pushArgument(); + break; + } + + if (char === '=') { + // this is a named argument! + currentArgumentName = currentArgumentValue; + currentArgumentValue = ''; + break; + } // add next character to argument + + + currentArgumentValue += char; + break; + + case 'after_arguments': + // the previous character was a ")" to end arguments + // ah, this was actually the end of a modifier! + if (char === '|') { + pushModifier(); + break; + } // we just finished an action(), and now we need a space + + + if (char !== ' ') { + throw new Error("Missing space after ".concat(getLastActionName(), "()")); + } + + pushInstruction(); + break; + } + } + + switch (state) { + case 'action': + case 'after_arguments': + if (currentActionName) { + pushInstruction(); + } + + break; + + default: + throw new Error("Did you forget to add a closing \")\" after \"".concat(currentActionName, "\"?")); + } + + return directives; +} \ No newline at end of file diff --git a/src/LiveComponent/assets/dist/http_data_helper.js b/src/LiveComponent/assets/dist/http_data_helper.js new file mode 100644 index 00000000000..fccfbc8d01a --- /dev/null +++ b/src/LiveComponent/assets/dist/http_data_helper.js @@ -0,0 +1,125 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.buildFormData = buildFormData; +exports.buildSearchParams = buildSearchParams; + +function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); } + +function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } + +function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } + +function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); } + +function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); } + +function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } + +/* + * Helper to convert a deep object of data into a format + * that can be transmitted as GET or POST data. + * + * Likely there is an easier way to do this with no duplication. + */ +var buildFormKey = function buildFormKey(key, parentKeys) { + var fieldName = ''; + [].concat(_toConsumableArray(parentKeys), [key]).forEach(function (name) { + fieldName += fieldName ? "[".concat(name, "]") : name; + }); + return fieldName; +}; +/** + * @param {FormData} formData + * @param {Object} data + * @param {Array} parentKeys + */ + + +var addObjectToFormData = function addObjectToFormData(formData, data, parentKeys) { + // todo - handles files + Object.keys(data).forEach(function (key) { + var value = data[key]; // TODO: there is probably a better way to normalize this + + if (value === true) { + value = 1; + } + + if (value === false) { + value = 0; + } // don't send null values at all + + + if (value === null) { + return; + } // handle embedded objects + + + if (_typeof(value) === 'object' && value !== null) { + addObjectToFormData(formData, value, [].concat(_toConsumableArray(parentKeys), [key])); + return; + } + + formData.append(buildFormKey(key, parentKeys), value); + }); +}; +/** + * @param {URLSearchParams} searchParams + * @param {Object} data + * @param {Array} parentKeys + */ + + +var addObjectToSearchParams = function addObjectToSearchParams(searchParams, data, parentKeys) { + Object.keys(data).forEach(function (key) { + var value = data[key]; // TODO: there is probably a better way to normalize this + // TODO: duplication + + if (value === true) { + value = 1; + } + + if (value === false) { + value = 0; + } // don't send null values at all + + + if (value === null) { + return; + } // handle embedded objects + + + if (_typeof(value) === 'object' && value !== null) { + addObjectToSearchParams(searchParams, value, [].concat(_toConsumableArray(parentKeys), [key])); + return; + } + + searchParams.set(buildFormKey(key, parentKeys), value); + }); +}; +/** + * @param {Object} data + * @return {FormData} + */ + + +function buildFormData(data) { + var formData = new FormData(); + addObjectToFormData(formData, data, []); + return formData; +} +/** + * @param {URLSearchParams} searchParams + * @param {Object} data + * @return {URLSearchParams} + */ + + +function buildSearchParams(searchParams, data) { + addObjectToSearchParams(searchParams, data, []); + return searchParams; +} \ No newline at end of file diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js new file mode 100644 index 00000000000..6d17e2a2563 --- /dev/null +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -0,0 +1,781 @@ +"use strict"; + +function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _stimulus = require("stimulus"); + +var _morphdom = _interopRequireDefault(require("morphdom")); + +var _directives_parser = require("./directives_parser"); + +var _string_utils = require("./string_utils"); + +var _http_data_helper = require("./http_data_helper"); + +var _set_deep_data = require("./set_deep_data"); + +require("./polyfills"); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); } + +function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } + +function _iterableToArrayLimit(arr, i) { var _i = arr == null ? null : typeof Symbol !== "undefined" && arr[Symbol.iterator] || arr["@@iterator"]; if (_i == null) return; var _arr = []; var _n = true; var _d = false; var _s, _e; try { for (_i = _i.call(arr); !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } + +function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } + +function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); } + +function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } + +function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } + +function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); } + +function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); } + +function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); } + +function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } + +function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; } + +function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } return _assertThisInitialized(self); } + +function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } + +function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } } + +function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); } + +function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } + +var DEFAULT_DEBOUNCE = '150'; + +var _default = /*#__PURE__*/function (_Controller) { + _inherits(_default, _Controller); + + var _super = _createSuper(_default); + + function _default() { + var _this; + + _classCallCheck(this, _default); + + for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { + args[_key] = arguments[_key]; + } + + _this = _super.call.apply(_super, [this].concat(args)); + + _defineProperty(_assertThisInitialized(_this), "renderDebounceTimeout", null); + + _defineProperty(_assertThisInitialized(_this), "actionDebounceTimeout", null); + + _defineProperty(_assertThisInitialized(_this), "renderPromiseStack", new PromiseStack()); + + _defineProperty(_assertThisInitialized(_this), "pollingIntervals", []); + + _defineProperty(_assertThisInitialized(_this), "isWindowUnloaded", false); + + _defineProperty(_assertThisInitialized(_this), "markAsWindowUnloaded", function () { + _this.isWindowUnloaded = true; + }); + + return _this; + } + + _createClass(_default, [{ + key: "initialize", + value: function initialize() { + this.markAsWindowUnloaded = this.markAsWindowUnloaded.bind(this); + } + }, { + key: "connect", + value: function connect() { + // hide "loading" elements to begin with + // This is done with CSS, but only for the most basic cases + this._onLoadingFinish(); + + if (this.element.dataset.poll !== undefined) { + this._initiatePolling(this.element.dataset.poll); + } + + window.addEventListener('beforeunload', this.markAsWindowUnloaded); + + this._dispatchEvent('live:connect'); + } + }, { + key: "disconnect", + value: function disconnect() { + this.pollingIntervals.forEach(function (interval) { + clearInterval(interval); + }); + window.removeEventListener('beforeunload', this.markAsWindowUnloaded); + } + /** + * Called to update one piece of the model + */ + + }, { + key: "update", + value: function update(event) { + var value = event.target.value; + + this._updateModelFromElement(event.target, value, true); + } + }, { + key: "updateDefer", + value: function updateDefer(event) { + var value = event.target.value; + + this._updateModelFromElement(event.target, value, false); + } + }, { + key: "action", + value: function action(event) { + var _this2 = this; + + // using currentTarget means that the data-action and data-action-name + // must live on the same element: you can't add + // data-action="click->live#action" on a parent element and + // expect it to use the data-action-name from the child element + // that actually received the click + var rawAction = event.currentTarget.dataset.actionName; // data-action-name="prevent|debounce(1000)|save" + + var directives = (0, _directives_parser.parseDirectives)(rawAction); + directives.forEach(function (directive) { + // set here so it can be delayed with debouncing below + var _executeAction = function _executeAction() { + // if any normal renders are waiting to start, cancel them + // allow the action to start and finish + // this covers a case where you "blur" a field to click "save" + // the "change" event will trigger first & schedule a re-render + // then the action Ajax will start. We want to avoid the + // re-render request from starting after the debounce and + // taking precedence + _this2._clearWaitingDebouncedRenders(); + + _this2._makeRequest(directive.action); + }; + + var handled = false; + directive.modifiers.forEach(function (modifier) { + switch (modifier.name) { + case 'prevent': + event.preventDefault(); + break; + + case 'stop': + event.stopPropagation(); + break; + + case 'self': + if (event.target !== event.currentTarget) { + return; + } + + break; + + case 'debounce': + { + var length = modifier.value ? modifier.value : DEFAULT_DEBOUNCE; // clear any pending renders + + if (_this2.actionDebounceTimeout) { + clearTimeout(_this2.actionDebounceTimeout); + _this2.actionDebounceTimeout = null; + } + + _this2.actionDebounceTimeout = setTimeout(function () { + _this2.actionDebounceTimeout = null; + + _executeAction(); + }, length); + handled = true; + break; + } + + default: + console.warn("Unknown modifier ".concat(modifier.name, " in action ").concat(rawAction)); + } + }); + + if (!handled) { + _executeAction(); + } + }); + } + }, { + key: "$render", + value: function $render() { + this._makeRequest(null); + } + }, { + key: "_updateModelFromElement", + value: function _updateModelFromElement(element, value, shouldRender) { + var model = element.dataset.model || element.getAttribute('name'); + + if (!model) { + var clonedElement = element.cloneNode(); + clonedElement.innerHTML = ''; + throw new Error("The update() method could not be called for \"".concat(clonedElement.outerHTML, "\": the element must either have a \"data-model\" or \"name\" attribute set to the model name.")); + } + + this.$updateModel(model, value, element, shouldRender); + } + }, { + key: "$updateModel", + value: function $updateModel(model, value, element) { + var _this3 = this; + + var shouldRender = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true; + var directives = (0, _directives_parser.parseDirectives)(model); + + if (directives.length > 1) { + throw new Error("The data-model=\"".concat(model, "\" format is invalid: it does not support multiple directives (i.e. remove any spaces).")); + } + + var directive = directives[0]; + + if (directive.args.length > 0 || directive.named.length > 0) { + throw new Error("The data-model=\"".concat(model, "\" format is invalid: it does not support passing arguments to the model.")); + } + + var modelName = (0, _set_deep_data.normalizeModelName)(directive.action); // if there is a "validatedFields" data, it means this component wants + // to track which fields have been / should be validated. + // in that case, when the model is updated, mark that it should be validated + + if (this.dataValue.validatedFields !== undefined) { + var validatedFields = _toConsumableArray(this.dataValue.validatedFields); + + if (validatedFields.indexOf(modelName) === -1) { + validatedFields.push(modelName); + } + + this.dataValue = (0, _set_deep_data.setDeepData)(this.dataValue, 'validatedFields', validatedFields); + } + + if (!(0, _set_deep_data.doesDeepPropertyExist)(this.dataValue, modelName)) { + console.warn("Model \"".concat(modelName, "\" is not a valid data-model value")); + } // we do not send old and new data to the server + // we merge in the new data now + // TODO: handle edge case for top-level of a model with "exposed" props + // For example, suppose there is a "post" field but "post.title" is exposed. + // If there is a data-model="post", then the "post" data - which was + // previously an array with "id" and "title" fields - will now be set + // directly to the new post id (e.g. 4). From a saving standpoint, + // that is fine: the server sees the "4" and uses it for the post data. + // However, there is an edge case where the user changes data-model="post" + // and then, for some reason, they don't want an immediate re-render. + // Then, then modify the data-model="post.title" field. In theory, + // we should be smart enough to convert the post data - which is now + // the string "4" - back into an array with [id=4, title=new_title]. + + + this.dataValue = (0, _set_deep_data.setDeepData)(this.dataValue, modelName, value); + directive.modifiers.forEach(function (modifier) { + switch (modifier.name) { + // there are currently no data-model modifiers + default: + throw new Error("Unknown modifier ".concat(modifier.name, " used in data-model=\"").concat(model, "\"")); + } + }); + + if (shouldRender) { + // clear any pending renders + this._clearWaitingDebouncedRenders(); + + this.renderDebounceTimeout = setTimeout(function () { + _this3.renderDebounceTimeout = null; + + _this3.$render(); + }, this.debounceValue || DEFAULT_DEBOUNCE); + } + } + }, { + key: "_makeRequest", + value: function _makeRequest(action) { + var _this4 = this; + + var _this$urlValue$split = this.urlValue.split('?'), + _this$urlValue$split2 = _slicedToArray(_this$urlValue$split, 2), + url = _this$urlValue$split2[0], + queryString = _this$urlValue$split2[1]; + + var params = new URLSearchParams(queryString || ''); + var fetchOptions = { + headers: { + 'Accept': 'application/vnd.live-component+json' + } + }; + + if (action) { + url += "/".concat(encodeURIComponent(action)); + + if (this.csrfValue) { + fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfValue; + } + } + + if (!action && this._willDataFitInUrl()) { + (0, _http_data_helper.buildSearchParams)(params, this.dataValue); + fetchOptions.method = 'GET'; + } else { + fetchOptions.method = 'POST'; + fetchOptions.body = (0, _http_data_helper.buildFormData)(this.dataValue); + } + + this._onLoadingStart(); + + var paramsString = params.toString(); + var thisPromise = fetch("".concat(url).concat(paramsString.length > 0 ? "?".concat(paramsString) : ''), fetchOptions); + this.renderPromiseStack.addPromise(thisPromise); + thisPromise.then(function (response) { + // if another re-render is scheduled, do not "run it over" + if (_this4.renderDebounceTimeout) { + return; + } + + var isMostRecent = _this4.renderPromiseStack.removePromise(thisPromise); + + if (isMostRecent) { + response.json().then(function (data) { + _this4._processRerender(data); + }); + } + }); + } + /** + * Processes the response from an AJAX call and uses it to re-render. + * + * @private + */ + + }, { + key: "_processRerender", + value: function _processRerender(data) { + // check if the page is navigating away + if (this.isWindowUnloaded) { + return; + } + + if (data.redirect_url) { + // action returned a redirect + + /* global Turbo */ + if (typeof Turbo !== 'undefined') { + Turbo.visit(data.redirect_url); + } else { + window.location = data.redirect_url; + } + + return; + } + + if (!this._dispatchEvent('live:render', data, true, true)) { + // preventDefault() was called + return; + } // remove the loading behavior now so that when we morphdom + // "diffs" the elements, any loading differences will not cause + // elements to appear different unnecessarily + + + this._onLoadingFinish(); // merge/patch in the new HTML + + + this._executeMorphdom(data.html); // "data" holds the new, updated data + + + this.dataValue = data.data; + } + }, { + key: "_clearWaitingDebouncedRenders", + value: function _clearWaitingDebouncedRenders() { + if (this.renderDebounceTimeout) { + clearTimeout(this.renderDebounceTimeout); + this.renderDebounceTimeout = null; + } + } + }, { + key: "_onLoadingStart", + value: function _onLoadingStart() { + this._handleLoadingToggle(true); + } + }, { + key: "_onLoadingFinish", + value: function _onLoadingFinish() { + this._handleLoadingToggle(false); + } + }, { + key: "_handleLoadingToggle", + value: function _handleLoadingToggle(isLoading) { + var _this5 = this; + + this._getLoadingDirectives().forEach(function (_ref) { + var element = _ref.element, + directives = _ref.directives; + + // so we can track, at any point, if an element is in a "loading" state + if (isLoading) { + _this5._addAttributes(element, ['data-live-is-loading']); + } else { + _this5._removeAttributes(element, ['data-live-is-loading']); + } + + directives.forEach(function (directive) { + _this5._handleLoadingDirective(element, isLoading, directive); + }); + }); + } + /** + * @param {Element} element + * @param {boolean} isLoading + * @param {Directive} directive + * @private + */ + + }, { + key: "_handleLoadingDirective", + value: function _handleLoadingDirective(element, isLoading, directive) { + var _this6 = this; + + var finalAction = parseLoadingAction(directive.action, isLoading); + var loadingDirective = null; + + switch (finalAction) { + case 'show': + loadingDirective = function loadingDirective() { + _this6._showElement(element); + }; + + break; + + case 'hide': + loadingDirective = function loadingDirective() { + return _this6._hideElement(element); + }; + + break; + + case 'addClass': + loadingDirective = function loadingDirective() { + return _this6._addClass(element, directive.args); + }; + + break; + + case 'removeClass': + loadingDirective = function loadingDirective() { + return _this6._removeClass(element, directive.args); + }; + + break; + + case 'addAttribute': + loadingDirective = function loadingDirective() { + return _this6._addAttributes(element, directive.args); + }; + + break; + + case 'removeAttribute': + loadingDirective = function loadingDirective() { + return _this6._removeAttributes(element, directive.args); + }; + + break; + + default: + throw new Error("Unknown data-loading action \"".concat(finalAction, "\"")); + } + + var isHandled = false; + directive.modifiers.forEach(function (modifier) { + switch (modifier.name) { + case 'delay': + { + // if loading has *stopped*, the delay modifier has no effect + if (!isLoading) { + break; + } + + var delayLength = modifier.value || 200; + setTimeout(function () { + if (element.hasAttribute('data-live-is-loading')) { + loadingDirective(); + } + }, delayLength); + isHandled = true; + break; + } + + default: + throw new Error("Unknown modifier ".concat(modifier.name, " used in the loading directive ").concat(directive.getString())); + } + }); // execute the loading directive + + if (!isHandled) { + loadingDirective(); + } + } + }, { + key: "_getLoadingDirectives", + value: function _getLoadingDirectives() { + var loadingDirectives = []; + this.element.querySelectorAll('[data-loading]').forEach(function (element) { + // use "show" if the attribute is empty + var directives = (0, _directives_parser.parseDirectives)(element.dataset.loading || 'show'); + loadingDirectives.push({ + element: element, + directives: directives + }); + }); + return loadingDirectives; + } + }, { + key: "_showElement", + value: function _showElement(element) { + element.style.display = 'inline-block'; + } + }, { + key: "_hideElement", + value: function _hideElement(element) { + element.style.display = 'none'; + } + }, { + key: "_addClass", + value: function _addClass(element, classes) { + var _element$classList; + + (_element$classList = element.classList).add.apply(_element$classList, _toConsumableArray((0, _string_utils.combineSpacedArray)(classes))); + } + }, { + key: "_removeClass", + value: function _removeClass(element, classes) { + var _element$classList2; + + (_element$classList2 = element.classList).remove.apply(_element$classList2, _toConsumableArray((0, _string_utils.combineSpacedArray)(classes))); // remove empty class="" to avoid morphdom "diff" problem + + + if (element.classList.length === 0) { + this._removeAttributes(element, ['class']); + } + } + }, { + key: "_addAttributes", + value: function _addAttributes(element, attributes) { + attributes.forEach(function (attribute) { + element.setAttribute(attribute, ''); + }); + } + }, { + key: "_removeAttributes", + value: function _removeAttributes(element, attributes) { + attributes.forEach(function (attribute) { + element.removeAttribute(attribute); + }); + } + }, { + key: "_willDataFitInUrl", + value: function _willDataFitInUrl() { + // if the URL gets remotely close to 2000 chars, it may not fit + return Object.values(this.dataValue).join(',').length < 1500; + } + }, { + key: "_executeMorphdom", + value: function _executeMorphdom(newHtml) { + var _this7 = this; + + // https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro#answer-35385518 + function htmlToElement(html) { + var template = document.createElement('template'); + html = html.trim(); + template.innerHTML = html; + return template.content.firstChild; + } + + var newElement = htmlToElement(newHtml); + (0, _morphdom.default)(this.element, newElement, { + onBeforeElUpdated: function onBeforeElUpdated(fromEl, toEl) { + // https://github.com/patrick-steele-idem/morphdom#can-i-make-morphdom-blaze-through-the-dom-tree-even-faster-yes + if (fromEl.isEqualNode(toEl)) { + return false; + } // avoid updating child components: they will handle themselves + + + if (fromEl.hasAttribute('data-controller') && fromEl.getAttribute('data-controller').split(' ').indexOf('live') !== -1 && fromEl !== _this7.element) { + return false; + } + + return true; + } + }); + } + }, { + key: "_initiatePolling", + value: function _initiatePolling(rawPollConfig) { + var _this8 = this; + + var directives = (0, _directives_parser.parseDirectives)(rawPollConfig || '$render'); + directives.forEach(function (directive) { + var duration = 2000; + directive.modifiers.forEach(function (modifier) { + switch (modifier.name) { + case 'delay': + if (modifier.value) { + duration = modifier.value; + } + + break; + + default: + console.warn("Unknown modifier \"".concat(modifier.name, "\" in data-poll \"").concat(rawPollConfig, "\".")); + } + }); + + _this8.startPoll(directive.action, duration); + }); + } + }, { + key: "startPoll", + value: function startPoll(actionName, duration) { + var _this9 = this; + + var callback; + + if (actionName.charAt(0) === '$') { + callback = function callback() { + _this9[actionName](); + }; + } else { + callback = function callback() { + _this9._makeRequest(actionName); + }; + } + + this.pollingIntervals.push(setInterval(function () { + callback(); + }, duration)); + } + }, { + key: "_dispatchEvent", + value: function _dispatchEvent(name) { + var payload = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; + var canBubble = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : true; + var cancelable = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + var userEvent = new CustomEvent(name, { + bubbles: canBubble, + cancelable: cancelable, + detail: payload + }); + return this.element.dispatchEvent(userEvent); + } + }]); + + return _default; +}(_stimulus.Controller); +/** + * Tracks the current "re-render" promises. + */ + + +exports.default = _default; + +_defineProperty(_default, "values", { + url: String, + data: Object, + csrf: String, + + /** + * The Debounce timeout. + * + * Default: 150 + */ + debounce: Number +}); + +var PromiseStack = /*#__PURE__*/function () { + function PromiseStack() { + _classCallCheck(this, PromiseStack); + + _defineProperty(this, "stack", []); + } + + _createClass(PromiseStack, [{ + key: "addPromise", + value: function addPromise(promise) { + this.stack.push(promise); + } + /** + * Removes the promise AND returns `true` if it is the most recent. + * + * @param {Promise} promise + * @return {boolean} + */ + + }, { + key: "removePromise", + value: function removePromise(promise) { + var index = this.findPromiseIndex(promise); // promise was not found - it was removed because a new Promise + // already resolved before it + + if (index === -1) { + return false; + } // "save" whether this is the most recent or not + + + var isMostRecent = this.stack.length === index + 1; // remove all promises starting from the oldest up through this one + + this.stack.splice(0, index + 1); + return isMostRecent; + } + }, { + key: "findPromiseIndex", + value: function findPromiseIndex(promise) { + return this.stack.findIndex(function (item) { + return item === promise; + }); + } + }]); + + return PromiseStack; +}(); + +var parseLoadingAction = function parseLoadingAction(action, isLoading) { + switch (action) { + case 'show': + return isLoading ? 'show' : 'hide'; + + case 'hide': + return isLoading ? 'hide' : 'show'; + + case 'addClass': + return isLoading ? 'addClass' : 'removeClass'; + + case 'removeClass': + return isLoading ? 'removeClass' : 'addClass'; + + case 'addAttribute': + return isLoading ? 'addAttribute' : 'removeAttribute'; + + case 'removeAttribute': + return isLoading ? 'removeAttribute' : 'addAttribute'; + } + + throw new Error("Unknown data-loading action \"".concat(action, "\"")); +}; \ No newline at end of file diff --git a/src/LiveComponent/assets/dist/polyfills.js b/src/LiveComponent/assets/dist/polyfills.js new file mode 100644 index 00000000000..e1ba6c6d06d --- /dev/null +++ b/src/LiveComponent/assets/dist/polyfills.js @@ -0,0 +1,659 @@ +"use strict"; + +require("core-js/modules/es.symbol.js"); + +require("core-js/modules/es.symbol.description.js"); + +require("core-js/modules/es.symbol.async-iterator.js"); + +require("core-js/modules/es.symbol.has-instance.js"); + +require("core-js/modules/es.symbol.is-concat-spreadable.js"); + +require("core-js/modules/es.symbol.iterator.js"); + +require("core-js/modules/es.symbol.match.js"); + +require("core-js/modules/es.symbol.match-all.js"); + +require("core-js/modules/es.symbol.replace.js"); + +require("core-js/modules/es.symbol.search.js"); + +require("core-js/modules/es.symbol.species.js"); + +require("core-js/modules/es.symbol.split.js"); + +require("core-js/modules/es.symbol.to-primitive.js"); + +require("core-js/modules/es.symbol.to-string-tag.js"); + +require("core-js/modules/es.symbol.unscopables.js"); + +require("core-js/modules/es.aggregate-error.js"); + +require("core-js/modules/es.array.concat.js"); + +require("core-js/modules/es.array.copy-within.js"); + +require("core-js/modules/es.array.fill.js"); + +require("core-js/modules/es.array.filter.js"); + +require("core-js/modules/es.array.find.js"); + +require("core-js/modules/es.array.find-index.js"); + +require("core-js/modules/es.array.flat.js"); + +require("core-js/modules/es.array.flat-map.js"); + +require("core-js/modules/es.array.from.js"); + +require("core-js/modules/es.array.includes.js"); + +require("core-js/modules/es.array.iterator.js"); + +require("core-js/modules/es.array.join.js"); + +require("core-js/modules/es.array.map.js"); + +require("core-js/modules/es.array.of.js"); + +require("core-js/modules/es.array.slice.js"); + +require("core-js/modules/es.array.sort.js"); + +require("core-js/modules/es.array.species.js"); + +require("core-js/modules/es.array.splice.js"); + +require("core-js/modules/es.array.unscopables.flat.js"); + +require("core-js/modules/es.array.unscopables.flat-map.js"); + +require("core-js/modules/es.array-buffer.constructor.js"); + +require("core-js/modules/es.date.to-primitive.js"); + +require("core-js/modules/es.function.has-instance.js"); + +require("core-js/modules/es.function.name.js"); + +require("core-js/modules/es.global-this.js"); + +require("core-js/modules/es.json.stringify.js"); + +require("core-js/modules/es.json.to-string-tag.js"); + +require("core-js/modules/es.map.js"); + +require("core-js/modules/es.math.acosh.js"); + +require("core-js/modules/es.math.asinh.js"); + +require("core-js/modules/es.math.atanh.js"); + +require("core-js/modules/es.math.cbrt.js"); + +require("core-js/modules/es.math.clz32.js"); + +require("core-js/modules/es.math.cosh.js"); + +require("core-js/modules/es.math.expm1.js"); + +require("core-js/modules/es.math.fround.js"); + +require("core-js/modules/es.math.hypot.js"); + +require("core-js/modules/es.math.imul.js"); + +require("core-js/modules/es.math.log10.js"); + +require("core-js/modules/es.math.log1p.js"); + +require("core-js/modules/es.math.log2.js"); + +require("core-js/modules/es.math.sign.js"); + +require("core-js/modules/es.math.sinh.js"); + +require("core-js/modules/es.math.tanh.js"); + +require("core-js/modules/es.math.to-string-tag.js"); + +require("core-js/modules/es.math.trunc.js"); + +require("core-js/modules/es.number.constructor.js"); + +require("core-js/modules/es.number.epsilon.js"); + +require("core-js/modules/es.number.is-finite.js"); + +require("core-js/modules/es.number.is-integer.js"); + +require("core-js/modules/es.number.is-nan.js"); + +require("core-js/modules/es.number.is-safe-integer.js"); + +require("core-js/modules/es.number.max-safe-integer.js"); + +require("core-js/modules/es.number.min-safe-integer.js"); + +require("core-js/modules/es.number.parse-float.js"); + +require("core-js/modules/es.number.parse-int.js"); + +require("core-js/modules/es.number.to-fixed.js"); + +require("core-js/modules/es.object.assign.js"); + +require("core-js/modules/es.object.define-getter.js"); + +require("core-js/modules/es.object.define-setter.js"); + +require("core-js/modules/es.object.entries.js"); + +require("core-js/modules/es.object.freeze.js"); + +require("core-js/modules/es.object.from-entries.js"); + +require("core-js/modules/es.object.get-own-property-descriptor.js"); + +require("core-js/modules/es.object.get-own-property-descriptors.js"); + +require("core-js/modules/es.object.get-own-property-names.js"); + +require("core-js/modules/es.object.get-prototype-of.js"); + +require("core-js/modules/es.object.is.js"); + +require("core-js/modules/es.object.is-extensible.js"); + +require("core-js/modules/es.object.is-frozen.js"); + +require("core-js/modules/es.object.is-sealed.js"); + +require("core-js/modules/es.object.keys.js"); + +require("core-js/modules/es.object.lookup-getter.js"); + +require("core-js/modules/es.object.lookup-setter.js"); + +require("core-js/modules/es.object.prevent-extensions.js"); + +require("core-js/modules/es.object.seal.js"); + +require("core-js/modules/es.object.to-string.js"); + +require("core-js/modules/es.object.values.js"); + +require("core-js/modules/es.promise.js"); + +require("core-js/modules/es.promise.all-settled.js"); + +require("core-js/modules/es.promise.any.js"); + +require("core-js/modules/es.promise.finally.js"); + +require("core-js/modules/es.reflect.apply.js"); + +require("core-js/modules/es.reflect.construct.js"); + +require("core-js/modules/es.reflect.define-property.js"); + +require("core-js/modules/es.reflect.delete-property.js"); + +require("core-js/modules/es.reflect.get.js"); + +require("core-js/modules/es.reflect.get-own-property-descriptor.js"); + +require("core-js/modules/es.reflect.get-prototype-of.js"); + +require("core-js/modules/es.reflect.has.js"); + +require("core-js/modules/es.reflect.is-extensible.js"); + +require("core-js/modules/es.reflect.own-keys.js"); + +require("core-js/modules/es.reflect.prevent-extensions.js"); + +require("core-js/modules/es.reflect.set.js"); + +require("core-js/modules/es.reflect.set-prototype-of.js"); + +require("core-js/modules/es.reflect.to-string-tag.js"); + +require("core-js/modules/es.regexp.constructor.js"); + +require("core-js/modules/es.regexp.exec.js"); + +require("core-js/modules/es.regexp.flags.js"); + +require("core-js/modules/es.regexp.sticky.js"); + +require("core-js/modules/es.regexp.test.js"); + +require("core-js/modules/es.regexp.to-string.js"); + +require("core-js/modules/es.set.js"); + +require("core-js/modules/es.string.code-point-at.js"); + +require("core-js/modules/es.string.ends-with.js"); + +require("core-js/modules/es.string.from-code-point.js"); + +require("core-js/modules/es.string.includes.js"); + +require("core-js/modules/es.string.iterator.js"); + +require("core-js/modules/es.string.match.js"); + +require("core-js/modules/es.string.match-all.js"); + +require("core-js/modules/es.string.pad-end.js"); + +require("core-js/modules/es.string.pad-start.js"); + +require("core-js/modules/es.string.raw.js"); + +require("core-js/modules/es.string.repeat.js"); + +require("core-js/modules/es.string.replace.js"); + +require("core-js/modules/es.string.replace-all.js"); + +require("core-js/modules/es.string.search.js"); + +require("core-js/modules/es.string.split.js"); + +require("core-js/modules/es.string.starts-with.js"); + +require("core-js/modules/es.string.trim.js"); + +require("core-js/modules/es.string.trim-end.js"); + +require("core-js/modules/es.string.trim-start.js"); + +require("core-js/modules/es.string.anchor.js"); + +require("core-js/modules/es.string.big.js"); + +require("core-js/modules/es.string.blink.js"); + +require("core-js/modules/es.string.bold.js"); + +require("core-js/modules/es.string.fixed.js"); + +require("core-js/modules/es.string.fontcolor.js"); + +require("core-js/modules/es.string.fontsize.js"); + +require("core-js/modules/es.string.italics.js"); + +require("core-js/modules/es.string.link.js"); + +require("core-js/modules/es.string.small.js"); + +require("core-js/modules/es.string.strike.js"); + +require("core-js/modules/es.string.sub.js"); + +require("core-js/modules/es.string.sup.js"); + +require("core-js/modules/es.typed-array.float32-array.js"); + +require("core-js/modules/es.typed-array.float64-array.js"); + +require("core-js/modules/es.typed-array.int8-array.js"); + +require("core-js/modules/es.typed-array.int16-array.js"); + +require("core-js/modules/es.typed-array.int32-array.js"); + +require("core-js/modules/es.typed-array.uint8-array.js"); + +require("core-js/modules/es.typed-array.uint8-clamped-array.js"); + +require("core-js/modules/es.typed-array.uint16-array.js"); + +require("core-js/modules/es.typed-array.uint32-array.js"); + +require("core-js/modules/es.typed-array.copy-within.js"); + +require("core-js/modules/es.typed-array.every.js"); + +require("core-js/modules/es.typed-array.fill.js"); + +require("core-js/modules/es.typed-array.filter.js"); + +require("core-js/modules/es.typed-array.find.js"); + +require("core-js/modules/es.typed-array.find-index.js"); + +require("core-js/modules/es.typed-array.for-each.js"); + +require("core-js/modules/es.typed-array.from.js"); + +require("core-js/modules/es.typed-array.includes.js"); + +require("core-js/modules/es.typed-array.index-of.js"); + +require("core-js/modules/es.typed-array.iterator.js"); + +require("core-js/modules/es.typed-array.join.js"); + +require("core-js/modules/es.typed-array.last-index-of.js"); + +require("core-js/modules/es.typed-array.map.js"); + +require("core-js/modules/es.typed-array.of.js"); + +require("core-js/modules/es.typed-array.reduce.js"); + +require("core-js/modules/es.typed-array.reduce-right.js"); + +require("core-js/modules/es.typed-array.reverse.js"); + +require("core-js/modules/es.typed-array.set.js"); + +require("core-js/modules/es.typed-array.slice.js"); + +require("core-js/modules/es.typed-array.some.js"); + +require("core-js/modules/es.typed-array.sort.js"); + +require("core-js/modules/es.typed-array.subarray.js"); + +require("core-js/modules/es.typed-array.to-locale-string.js"); + +require("core-js/modules/es.typed-array.to-string.js"); + +require("core-js/modules/es.weak-map.js"); + +require("core-js/modules/es.weak-set.js"); + +require("core-js/modules/esnext.aggregate-error.js"); + +require("core-js/modules/esnext.array.at.js"); + +require("core-js/modules/esnext.array.filter-out.js"); + +require("core-js/modules/esnext.array.find-last.js"); + +require("core-js/modules/esnext.array.find-last-index.js"); + +require("core-js/modules/esnext.array.is-template-object.js"); + +require("core-js/modules/esnext.array.last-index.js"); + +require("core-js/modules/esnext.array.last-item.js"); + +require("core-js/modules/esnext.array.unique-by.js"); + +require("core-js/modules/esnext.async-iterator.constructor.js"); + +require("core-js/modules/esnext.async-iterator.as-indexed-pairs.js"); + +require("core-js/modules/esnext.async-iterator.drop.js"); + +require("core-js/modules/esnext.async-iterator.every.js"); + +require("core-js/modules/esnext.async-iterator.filter.js"); + +require("core-js/modules/esnext.async-iterator.find.js"); + +require("core-js/modules/esnext.async-iterator.flat-map.js"); + +require("core-js/modules/esnext.async-iterator.for-each.js"); + +require("core-js/modules/esnext.async-iterator.from.js"); + +require("core-js/modules/esnext.async-iterator.map.js"); + +require("core-js/modules/esnext.async-iterator.reduce.js"); + +require("core-js/modules/esnext.async-iterator.some.js"); + +require("core-js/modules/esnext.async-iterator.take.js"); + +require("core-js/modules/esnext.async-iterator.to-array.js"); + +require("core-js/modules/esnext.bigint.range.js"); + +require("core-js/modules/esnext.composite-key.js"); + +require("core-js/modules/esnext.composite-symbol.js"); + +require("core-js/modules/esnext.global-this.js"); + +require("core-js/modules/esnext.iterator.constructor.js"); + +require("core-js/modules/esnext.iterator.as-indexed-pairs.js"); + +require("core-js/modules/esnext.iterator.drop.js"); + +require("core-js/modules/esnext.iterator.every.js"); + +require("core-js/modules/esnext.iterator.filter.js"); + +require("core-js/modules/esnext.iterator.find.js"); + +require("core-js/modules/esnext.iterator.flat-map.js"); + +require("core-js/modules/esnext.iterator.for-each.js"); + +require("core-js/modules/esnext.iterator.from.js"); + +require("core-js/modules/esnext.iterator.map.js"); + +require("core-js/modules/esnext.iterator.reduce.js"); + +require("core-js/modules/esnext.iterator.some.js"); + +require("core-js/modules/esnext.iterator.take.js"); + +require("core-js/modules/esnext.iterator.to-array.js"); + +require("core-js/modules/esnext.map.delete-all.js"); + +require("core-js/modules/esnext.map.emplace.js"); + +require("core-js/modules/esnext.map.every.js"); + +require("core-js/modules/esnext.map.filter.js"); + +require("core-js/modules/esnext.map.find.js"); + +require("core-js/modules/esnext.map.find-key.js"); + +require("core-js/modules/esnext.map.from.js"); + +require("core-js/modules/esnext.map.group-by.js"); + +require("core-js/modules/esnext.map.includes.js"); + +require("core-js/modules/esnext.map.key-by.js"); + +require("core-js/modules/esnext.map.key-of.js"); + +require("core-js/modules/esnext.map.map-keys.js"); + +require("core-js/modules/esnext.map.map-values.js"); + +require("core-js/modules/esnext.map.merge.js"); + +require("core-js/modules/esnext.map.of.js"); + +require("core-js/modules/esnext.map.reduce.js"); + +require("core-js/modules/esnext.map.some.js"); + +require("core-js/modules/esnext.map.update.js"); + +require("core-js/modules/esnext.map.update-or-insert.js"); + +require("core-js/modules/esnext.map.upsert.js"); + +require("core-js/modules/esnext.math.clamp.js"); + +require("core-js/modules/esnext.math.deg-per-rad.js"); + +require("core-js/modules/esnext.math.degrees.js"); + +require("core-js/modules/esnext.math.fscale.js"); + +require("core-js/modules/esnext.math.iaddh.js"); + +require("core-js/modules/esnext.math.imulh.js"); + +require("core-js/modules/esnext.math.isubh.js"); + +require("core-js/modules/esnext.math.rad-per-deg.js"); + +require("core-js/modules/esnext.math.radians.js"); + +require("core-js/modules/esnext.math.scale.js"); + +require("core-js/modules/esnext.math.seeded-prng.js"); + +require("core-js/modules/esnext.math.signbit.js"); + +require("core-js/modules/esnext.math.umulh.js"); + +require("core-js/modules/esnext.number.from-string.js"); + +require("core-js/modules/esnext.number.range.js"); + +require("core-js/modules/esnext.object.has-own.js"); + +require("core-js/modules/esnext.object.iterate-entries.js"); + +require("core-js/modules/esnext.object.iterate-keys.js"); + +require("core-js/modules/esnext.object.iterate-values.js"); + +require("core-js/modules/esnext.observable.js"); + +require("core-js/modules/esnext.promise.all-settled.js"); + +require("core-js/modules/esnext.promise.any.js"); + +require("core-js/modules/esnext.promise.try.js"); + +require("core-js/modules/esnext.reflect.define-metadata.js"); + +require("core-js/modules/esnext.reflect.delete-metadata.js"); + +require("core-js/modules/esnext.reflect.get-metadata.js"); + +require("core-js/modules/esnext.reflect.get-metadata-keys.js"); + +require("core-js/modules/esnext.reflect.get-own-metadata.js"); + +require("core-js/modules/esnext.reflect.get-own-metadata-keys.js"); + +require("core-js/modules/esnext.reflect.has-metadata.js"); + +require("core-js/modules/esnext.reflect.has-own-metadata.js"); + +require("core-js/modules/esnext.reflect.metadata.js"); + +require("core-js/modules/esnext.set.add-all.js"); + +require("core-js/modules/esnext.set.delete-all.js"); + +require("core-js/modules/esnext.set.difference.js"); + +require("core-js/modules/esnext.set.every.js"); + +require("core-js/modules/esnext.set.filter.js"); + +require("core-js/modules/esnext.set.find.js"); + +require("core-js/modules/esnext.set.from.js"); + +require("core-js/modules/esnext.set.intersection.js"); + +require("core-js/modules/esnext.set.is-disjoint-from.js"); + +require("core-js/modules/esnext.set.is-subset-of.js"); + +require("core-js/modules/esnext.set.is-superset-of.js"); + +require("core-js/modules/esnext.set.join.js"); + +require("core-js/modules/esnext.set.map.js"); + +require("core-js/modules/esnext.set.of.js"); + +require("core-js/modules/esnext.set.reduce.js"); + +require("core-js/modules/esnext.set.some.js"); + +require("core-js/modules/esnext.set.symmetric-difference.js"); + +require("core-js/modules/esnext.set.union.js"); + +require("core-js/modules/esnext.string.at.js"); + +require("core-js/modules/esnext.string.code-points.js"); + +require("core-js/modules/esnext.string.match-all.js"); + +require("core-js/modules/esnext.string.replace-all.js"); + +require("core-js/modules/esnext.symbol.async-dispose.js"); + +require("core-js/modules/esnext.symbol.dispose.js"); + +require("core-js/modules/esnext.symbol.matcher.js"); + +require("core-js/modules/esnext.symbol.metadata.js"); + +require("core-js/modules/esnext.symbol.observable.js"); + +require("core-js/modules/esnext.symbol.pattern-match.js"); + +require("core-js/modules/esnext.symbol.replace-all.js"); + +require("core-js/modules/esnext.typed-array.at.js"); + +require("core-js/modules/esnext.typed-array.filter-out.js"); + +require("core-js/modules/esnext.typed-array.find-last.js"); + +require("core-js/modules/esnext.typed-array.find-last-index.js"); + +require("core-js/modules/esnext.typed-array.unique-by.js"); + +require("core-js/modules/esnext.weak-map.delete-all.js"); + +require("core-js/modules/esnext.weak-map.from.js"); + +require("core-js/modules/esnext.weak-map.of.js"); + +require("core-js/modules/esnext.weak-map.emplace.js"); + +require("core-js/modules/esnext.weak-map.upsert.js"); + +require("core-js/modules/esnext.weak-set.add-all.js"); + +require("core-js/modules/esnext.weak-set.delete-all.js"); + +require("core-js/modules/esnext.weak-set.from.js"); + +require("core-js/modules/esnext.weak-set.of.js"); + +require("core-js/modules/web.dom-collections.for-each.js"); + +require("core-js/modules/web.dom-collections.iterator.js"); + +require("core-js/modules/web.queue-microtask.js"); + +require("core-js/modules/web.url.js"); + +require("core-js/modules/web.url.to-json.js"); + +require("core-js/modules/web.url-search-params.js"); \ No newline at end of file diff --git a/src/LiveComponent/assets/dist/set_deep_data.js b/src/LiveComponent/assets/dist/set_deep_data.js new file mode 100644 index 00000000000..8db20d572bc --- /dev/null +++ b/src/LiveComponent/assets/dist/set_deep_data.js @@ -0,0 +1,80 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.setDeepData = setDeepData; +exports.doesDeepPropertyExist = doesDeepPropertyExist; +exports.normalizeModelName = normalizeModelName; + +function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +// post.user.username +function setDeepData(data, propertyPath, value) { + // cheap way to deep clone simple data + var finalData = JSON.parse(JSON.stringify(data)); + var currentLevelData = finalData; + var parts = propertyPath.split('.'); // change currentLevelData to the final depth object + + for (var i = 0; i < parts.length - 1; i++) { + currentLevelData = currentLevelData[parts[i]]; + } // now finally change the key on that deeper object + + + var finalKey = parts[parts.length - 1]; // make sure the currentLevelData is an object, not a scalar + // if it is, it means the initial data didn't know that sub-properties + // could be exposed. Or, you're just trying to set some deep + // path - e.g. post.title - onto some property that is, for example, + // an integer (2). + + if (_typeof(currentLevelData) !== 'object') { + var lastPart = parts.pop(); + throw new Error("Cannot set data-model=\"".concat(propertyPath, "\". They parent \"").concat(parts.join(','), "\" data does not appear to be an object (it's \"").concat(currentLevelData, "\"). Did you forget to add exposed={\"").concat(lastPart, "\"} to its LiveProp?")); + } // represents a situation where the key you're setting *is* an object, + // but the key we're setting is a new key. This, perhaps, could be + // allowed. But right now, all keys should be initialized with the + // initial data. + + + if (currentLevelData[finalKey] === undefined) { + var _lastPart = parts.pop(); + + if (parts.length > 0) { + console.warn("The property used in data-model=\"".concat(propertyPath, "\" was never initialized. Did you forget to add exposed={\"").concat(_lastPart, "\"} to its LiveProp?")); + } else { + console.warn("The property used in data-model=\"".concat(propertyPath, "\" was never initialized. Did you forget to expose \"").concat(_lastPart, "\" as a LiveProp?")); + } + } + + currentLevelData[finalKey] = value; + return finalData; +} +/** + * Checks if the given propertyPath is for a valid top-level key. + * + * @param {Object} data + * @param {string} propertyPath + * @return {boolean} + */ + + +function doesDeepPropertyExist(data, propertyPath) { + var parts = propertyPath.split('.'); + return data[parts[0]] !== undefined; +} +/** + * Normalizes model names with [] into the "." syntax. + * + * For example: "user[firstName]" becomes "user.firstName" + * + * @param {string} model + * @return {string} + */ + + +function normalizeModelName(model) { + return model.split('[') // ['object', 'foo', 'bar', 'ya'] + .map(function (s) { + return s.replace(']', ''); + }).join('.'); +} \ No newline at end of file diff --git a/src/LiveComponent/assets/dist/string_utils.js b/src/LiveComponent/assets/dist/string_utils.js new file mode 100644 index 00000000000..ef1a6152d77 --- /dev/null +++ b/src/LiveComponent/assets/dist/string_utils.js @@ -0,0 +1,41 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.combineSpacedArray = combineSpacedArray; + +function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); } + +function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } + +function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } + +function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); } + +function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); } + +function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } + +/** + * Splits each string in an array containing a space into an extra array item: + * + * Input: + * [ + * 'foo', + * 'bar baz', + * ] + * + * Output: + * ['foo', 'bar', 'baz'] + * + * @param {string[]} parts + * @return {string[]} + */ +function combineSpacedArray(parts) { + var finalParts = []; + parts.forEach(function (part) { + finalParts.push.apply(finalParts, _toConsumableArray(part.split(' '))); + }); + return finalParts; +} \ No newline at end of file diff --git a/src/LiveComponent/assets/package.json b/src/LiveComponent/assets/package.json new file mode 100644 index 00000000000..4d39a462188 --- /dev/null +++ b/src/LiveComponent/assets/package.json @@ -0,0 +1,38 @@ +{ + "name": "@symfony/live-stimulus", + "description": "Live Component: bring server-side re-rendering & model binding to any element.", + "main": "dist/live_controller.js", + "version": "0.0.1", + "license": "MIT", + "scripts": { + "build": "babel src -d dist", + "test-only": "jest", + "test": "yarn build && yarn test-only", + "lint": "eslint src" + }, + "dependencies": { + "core-js": "^3.14.0", + "morphdom": "^2.6.1" + }, + "peerDependencies": { + "stimulus": "^2.0.0" + }, + "devDependencies": { + "@babel/cli": "^7.12.1", + "@babel/core": "^7.12.3", + "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/preset-env": "^7.12.7", + "@symfony/stimulus-testing": "^1.1.0", + "@testing-library/dom": "^7.31.0", + "@testing-library/user-event": "^13.1.9", + "fetch-mock-jest": "^1.5.1", + "node-fetch": "^2.6.1", + "stimulus": "^2.0.0" + }, + "jest": { + "testRegex": "test/.*\\.test.js", + "setupFilesAfterEnv": [ + "./test/setup.js" + ] + } +} diff --git a/src/LiveComponent/assets/src/directives_parser.js b/src/LiveComponent/assets/src/directives_parser.js new file mode 100644 index 00000000000..5ce639e94a2 --- /dev/null +++ b/src/LiveComponent/assets/src/directives_parser.js @@ -0,0 +1,228 @@ +/** + * A modifier for a directive + * + * @typedef {Object} DirectiveModifier + * @property {string} name The name of the modifier (e.g. delay) + * @property {string|null} value The value of the single argument or null if no argument + */ + +/** + * A directive with action, args and modifiers. + * + * @typedef {Object} Directive + * @property {string} action The name of the action (e.g. addClass) + * @property {string[]} args An array of unnamed arguments passed to the action + * @property {Object} named An object of named arguments + * @property {DirectiveModifier[]} modifiers Any modifiers applied to the action + * @property {function} getString() + */ + +/** + * Parses strings like "addClass(foo) removeAttribute(bar)" + * into an array of directives, with this format: + * + * [ + * { action: 'addClass', args: ['foo'], named: {}, modifiers: [] }, + * { action: 'removeAttribute', args: ['bar'], named: {}, modifiers: [] } + * ] + * + * This also handles named arguments + * + * save(foo=bar, baz=bazzles) + * + * Which would return: + * [ + * { action: 'save', args: [], named: { foo: 'bar', baz: 'bazzles }, modifiers: [] } + * ] + * + * @param {string} content The value of the attribute + * @return {Directive[]} + */ +export function parseDirectives(content) { + const directives = []; + + if (!content) { + return directives; + } + + let currentActionName = ''; + let currentArgumentName = ''; + let currentArgumentValue = ''; + let currentArguments = []; + let currentNamedArguments = {}; + let currentModifiers = []; + let state = 'action'; + + const getLastActionName = function() { + if (currentActionName) { + return currentActionName; + } + + if (directives.length === 0) { + throw new Error('Could not find any directives'); + } + + return directives[directives.length - 1].action; + } + const pushInstruction = function() { + directives.push({ + action: currentActionName, + args: currentArguments, + named: currentNamedArguments, + modifiers: currentModifiers, + getString: () => { + // TODO - make a string representation of JUST this directive + + return content; + } + }); + currentActionName = ''; + currentArgumentName = ''; + currentArgumentValue = ''; + currentArguments = []; + currentNamedArguments = {}; + currentModifiers = []; + state = 'action'; + } + const pushArgument = function() { + const mixedArgTypesError = () => { + throw new Error(`Normal and named arguments cannot be mixed inside "${currentActionName}()"`) + } + + if (currentArgumentName) { + if (currentArguments.length > 0) { + mixedArgTypesError(); + } + + // argument names are also trimmed to avoid space after "," + // "foo=bar, baz=bazzles" + currentNamedArguments[currentArgumentName.trim()] = currentArgumentValue; + } else { + if (Object.keys(currentNamedArguments).length > 0) { + mixedArgTypesError(); + } + + // value is trimmed to avoid space after "," + // "foo, bar" + currentArguments.push(currentArgumentValue.trim()); + } + currentArgumentName = ''; + currentArgumentValue = ''; + } + + const pushModifier = function() { + if (currentArguments.length > 1) { + throw new Error(`The modifier "${currentActionName}()" does not support multiple arguments.`) + } + + if (Object.keys(currentNamedArguments).length > 0) { + throw new Error(`The modifier "${currentActionName}()" does not support named arguments.`) + } + + currentModifiers.push({ + name: currentActionName, + value: currentArguments.length > 0 ? currentArguments[0] : null, + }); + currentActionName = ''; + currentArgumentName = ''; + currentArguments = []; + state = 'action'; + } + + for (var i = 0; i < content.length; i++) { + const char = content[i]; + switch(state) { + case 'action': + if (char === '(') { + state = 'arguments'; + + break; + } + + if (char === ' ') { + // this is the end of the action and it has no arguments + // if the action had args(), it was already recorded + if (currentActionName) { + pushInstruction(); + } + + break; + } + + if (char === '|') { + // ah, this was a modifier (with no arguments) + pushModifier(); + + break; + } + + // we're expecting more characters for an action name + currentActionName += char; + + break; + + case 'arguments': + if (char === ')') { + // end of the arguments for a modifier or the action + pushArgument(); + + state = 'after_arguments'; + + break; + } + + if (char === ',') { + // end of current argument + pushArgument(); + + break; + } + + if (char === '=') { + // this is a named argument! + currentArgumentName = currentArgumentValue; + currentArgumentValue = ''; + + break; + } + + // add next character to argument + currentArgumentValue += char; + + break; + + case 'after_arguments': + // the previous character was a ")" to end arguments + + // ah, this was actually the end of a modifier! + if (char === '|') { + pushModifier(); + + break; + } + + // we just finished an action(), and now we need a space + if (char !== ' ') { + throw new Error(`Missing space after ${getLastActionName()}()`) + } + + pushInstruction(); + + break; + } + } + + switch (state) { + case 'action': + case 'after_arguments': + if (currentActionName) { + pushInstruction(); + } + + break; + default: + throw new Error(`Did you forget to add a closing ")" after "${currentActionName}"?`) + } + + return directives; +} diff --git a/src/LiveComponent/assets/src/http_data_helper.js b/src/LiveComponent/assets/src/http_data_helper.js new file mode 100644 index 00000000000..b70eda17fdf --- /dev/null +++ b/src/LiveComponent/assets/src/http_data_helper.js @@ -0,0 +1,104 @@ +/* + * Helper to convert a deep object of data into a format + * that can be transmitted as GET or POST data. + * + * Likely there is an easier way to do this with no duplication. + */ + +const buildFormKey = function(key, parentKeys) { + let fieldName = ''; + [...parentKeys, key].forEach((name) => { + fieldName += fieldName ? `[${name}]` : name; + }); + + return fieldName; +} + +/** + * @param {FormData} formData + * @param {Object} data + * @param {Array} parentKeys + */ +const addObjectToFormData = function(formData, data, parentKeys) { + // todo - handles files + Object.keys(data).forEach((key => { + let value = data[key]; + + // TODO: there is probably a better way to normalize this + if (value === true) { + value = 1; + } + if (value === false) { + value = 0; + } + // don't send null values at all + if (value === null) { + return; + } + + // handle embedded objects + if (typeof value === 'object' && value !== null) { + addObjectToFormData(formData, value, [...parentKeys, key]); + + return; + } + + formData.append(buildFormKey(key, parentKeys), value); + })); +} + +/** + * @param {URLSearchParams} searchParams + * @param {Object} data + * @param {Array} parentKeys + */ +const addObjectToSearchParams = function(searchParams, data, parentKeys) { + Object.keys(data).forEach((key => { + let value = data[key]; + + // TODO: there is probably a better way to normalize this + // TODO: duplication + if (value === true) { + value = 1; + } + if (value === false) { + value = 0; + } + // don't send null values at all + if (value === null) { + return; + } + + // handle embedded objects + if (typeof value === 'object' && value !== null) { + addObjectToSearchParams(searchParams, value, [...parentKeys, key]); + + return; + } + + searchParams.set(buildFormKey(key, parentKeys), value); + })); +} + +/** + * @param {Object} data + * @return {FormData} + */ +export function buildFormData(data) { + const formData = new FormData(); + + addObjectToFormData(formData, data, []); + + return formData; +} + +/** + * @param {URLSearchParams} searchParams + * @param {Object} data + * @return {URLSearchParams} + */ +export function buildSearchParams(searchParams, data) { + addObjectToSearchParams(searchParams, data, []); + + return searchParams; +} diff --git a/src/LiveComponent/assets/src/live_controller.js b/src/LiveComponent/assets/src/live_controller.js new file mode 100644 index 00000000000..34c277b5762 --- /dev/null +++ b/src/LiveComponent/assets/src/live_controller.js @@ -0,0 +1,622 @@ +import { Controller } from 'stimulus'; +import morphdom from 'morphdom'; +import { parseDirectives } from './directives_parser'; +import { combineSpacedArray } from './string_utils'; +import { buildFormData, buildSearchParams } from './http_data_helper'; +import { setDeepData, doesDeepPropertyExist, normalizeModelName } from './set_deep_data'; +import './polyfills'; + +const DEFAULT_DEBOUNCE = '150'; + +export default class extends Controller { + static values = { + url: String, + data: Object, + csrf: String, + /** + * The Debounce timeout. + * + * Default: 150 + */ + debounce: Number, + } + + /** + * The current "timeout" that's waiting before a model update + * triggers a re-render. + */ + renderDebounceTimeout = null; + + /** + * The current "timeout" that's waiting before an action should + * be taken. + */ + actionDebounceTimeout = null; + + /** + * A stack of all current AJAX Promises for re-rendering. + * + * @type {PromiseStack} + */ + renderPromiseStack = new PromiseStack(); + + pollingIntervals = []; + + isWindowUnloaded = false; + + initialize() { + this.markAsWindowUnloaded = this.markAsWindowUnloaded.bind(this); + } + + connect() { + // hide "loading" elements to begin with + // This is done with CSS, but only for the most basic cases + this._onLoadingFinish(); + + if (this.element.dataset.poll !== undefined) { + this._initiatePolling(this.element.dataset.poll); + } + + window.addEventListener('beforeunload', this.markAsWindowUnloaded); + + this._dispatchEvent('live:connect'); + } + + disconnect() { + this.pollingIntervals.forEach((interval) => { + clearInterval(interval); + }); + + window.removeEventListener('beforeunload', this.markAsWindowUnloaded); + } + + /** + * Called to update one piece of the model + */ + update(event) { + const value = event.target.value; + + this._updateModelFromElement(event.target, value, true); + } + + updateDefer(event) { + const value = event.target.value; + + this._updateModelFromElement(event.target, value, false); + } + + action(event) { + // using currentTarget means that the data-action and data-action-name + // must live on the same element: you can't add + // data-action="click->live#action" on a parent element and + // expect it to use the data-action-name from the child element + // that actually received the click + const rawAction = event.currentTarget.dataset.actionName; + + // data-action-name="prevent|debounce(1000)|save" + const directives = parseDirectives(rawAction); + + directives.forEach((directive) => { + // set here so it can be delayed with debouncing below + const _executeAction = () => { + // if any normal renders are waiting to start, cancel them + // allow the action to start and finish + // this covers a case where you "blur" a field to click "save" + // the "change" event will trigger first & schedule a re-render + // then the action Ajax will start. We want to avoid the + // re-render request from starting after the debounce and + // taking precedence + this._clearWaitingDebouncedRenders(); + + this._makeRequest(directive.action); + } + + let handled = false; + directive.modifiers.forEach((modifier) => { + switch (modifier.name) { + case 'prevent': + event.preventDefault(); + break; + case 'stop': + event.stopPropagation(); + break; + case 'self': + if (event.target !== event.currentTarget) { + return; + } + break; + case 'debounce': { + const length = modifier.value ? modifier.value : DEFAULT_DEBOUNCE; + + // clear any pending renders + if (this.actionDebounceTimeout) { + clearTimeout(this.actionDebounceTimeout); + this.actionDebounceTimeout = null; + } + + this.actionDebounceTimeout = setTimeout(() => { + this.actionDebounceTimeout = null; + _executeAction(); + }, length); + + handled = true; + + break; + } + + default: + console.warn(`Unknown modifier ${modifier.name} in action ${rawAction}`); + } + }); + + if (!handled) { + _executeAction(); + } + }) + } + + $render() { + this._makeRequest(null); + } + + _updateModelFromElement(element, value, shouldRender) { + const model = element.dataset.model || element.getAttribute('name'); + + if (!model) { + const clonedElement = element.cloneNode(); + clonedElement.innerHTML = ''; + + throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-model" or "name" attribute set to the model name.`); + } + + this.$updateModel(model, value, element, shouldRender); + } + + $updateModel(model, value, element, shouldRender = true) { + const directives = parseDirectives(model); + if (directives.length > 1) { + throw new Error(`The data-model="${model}" format is invalid: it does not support multiple directives (i.e. remove any spaces).`); + } + + const directive = directives[0]; + + if (directive.args.length > 0 || directive.named.length > 0) { + throw new Error(`The data-model="${model}" format is invalid: it does not support passing arguments to the model.`); + } + + const modelName = normalizeModelName(directive.action); + + // if there is a "validatedFields" data, it means this component wants + // to track which fields have been / should be validated. + // in that case, when the model is updated, mark that it should be validated + if (this.dataValue.validatedFields !== undefined) { + const validatedFields = [...this.dataValue.validatedFields]; + if (validatedFields.indexOf(modelName) === -1) { + validatedFields.push(modelName); + } + this.dataValue = setDeepData(this.dataValue, 'validatedFields', validatedFields); + } + + if (!doesDeepPropertyExist(this.dataValue, modelName)) { + console.warn(`Model "${modelName}" is not a valid data-model value`); + } + + // we do not send old and new data to the server + // we merge in the new data now + // TODO: handle edge case for top-level of a model with "exposed" props + // For example, suppose there is a "post" field but "post.title" is exposed. + // If there is a data-model="post", then the "post" data - which was + // previously an array with "id" and "title" fields - will now be set + // directly to the new post id (e.g. 4). From a saving standpoint, + // that is fine: the server sees the "4" and uses it for the post data. + // However, there is an edge case where the user changes data-model="post" + // and then, for some reason, they don't want an immediate re-render. + // Then, then modify the data-model="post.title" field. In theory, + // we should be smart enough to convert the post data - which is now + // the string "4" - back into an array with [id=4, title=new_title]. + this.dataValue = setDeepData(this.dataValue, modelName, value); + + directive.modifiers.forEach((modifier => { + switch (modifier.name) { + // there are currently no data-model modifiers + default: + throw new Error(`Unknown modifier ${modifier.name} used in data-model="${model}"`) + } + })); + + if (shouldRender) { + // clear any pending renders + this._clearWaitingDebouncedRenders(); + + this.renderDebounceTimeout = setTimeout(() => { + this.renderDebounceTimeout = null; + this.$render(); + }, this.debounceValue || DEFAULT_DEBOUNCE); + } + } + + _makeRequest(action) { + let [url, queryString] = this.urlValue.split('?'); + const params = new URLSearchParams(queryString || ''); + + const fetchOptions = { + headers: { + 'Accept': 'application/vnd.live-component+json', + }, + }; + + if (action) { + url += `/${encodeURIComponent(action)}`; + + if (this.csrfValue) { + fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfValue; + } + } + + if (!action && this._willDataFitInUrl()) { + buildSearchParams(params, this.dataValue); + fetchOptions.method = 'GET'; + } else { + fetchOptions.method = 'POST'; + fetchOptions.body = buildFormData(this.dataValue); + } + + this._onLoadingStart(); + const paramsString = params.toString(); + const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions); + this.renderPromiseStack.addPromise(thisPromise); + thisPromise.then((response) => { + // if another re-render is scheduled, do not "run it over" + if (this.renderDebounceTimeout) { + return; + } + + const isMostRecent = this.renderPromiseStack.removePromise(thisPromise); + if (isMostRecent) { + response.json().then((data) => { + this._processRerender(data) + }); + } + }) + } + + /** + * Processes the response from an AJAX call and uses it to re-render. + * + * @private + */ + _processRerender(data) { + // check if the page is navigating away + if (this.isWindowUnloaded) { + return; + } + + if (data.redirect_url) { + // action returned a redirect + /* global Turbo */ + if (typeof Turbo !== 'undefined') { + Turbo.visit(data.redirect_url); + } else { + window.location = data.redirect_url; + } + + return; + } + + if (!this._dispatchEvent('live:render', data, true, true)) { + // preventDefault() was called + return; + } + + // remove the loading behavior now so that when we morphdom + // "diffs" the elements, any loading differences will not cause + // elements to appear different unnecessarily + this._onLoadingFinish(); + + // merge/patch in the new HTML + this._executeMorphdom(data.html); + + // "data" holds the new, updated data + this.dataValue = data.data; + } + + _clearWaitingDebouncedRenders() { + if (this.renderDebounceTimeout) { + clearTimeout(this.renderDebounceTimeout); + this.renderDebounceTimeout = null; + } + } + + _onLoadingStart() { + this._handleLoadingToggle(true); + } + + _onLoadingFinish() { + this._handleLoadingToggle(false); + } + + _handleLoadingToggle(isLoading) { + this._getLoadingDirectives().forEach(({ element, directives }) => { + // so we can track, at any point, if an element is in a "loading" state + if (isLoading) { + this._addAttributes(element, ['data-live-is-loading']); + } else { + this._removeAttributes(element, ['data-live-is-loading']); + } + + directives.forEach((directive) => { + this._handleLoadingDirective(element, isLoading, directive) + }); + }); + } + + /** + * @param {Element} element + * @param {boolean} isLoading + * @param {Directive} directive + * @private + */ + _handleLoadingDirective(element, isLoading, directive) { + const finalAction = parseLoadingAction(directive.action, isLoading); + + let loadingDirective = null; + + switch (finalAction) { + case 'show': + loadingDirective = () => { + this._showElement(element) + }; + break; + + case 'hide': + loadingDirective = () => this._hideElement(element); + break; + + case 'addClass': + loadingDirective = () => this._addClass(element, directive.args); + break; + + case 'removeClass': + loadingDirective = () => this._removeClass(element, directive.args); + break; + + case 'addAttribute': + loadingDirective = () => this._addAttributes(element, directive.args); + break; + + case 'removeAttribute': + loadingDirective = () => this._removeAttributes(element, directive.args); + break; + + default: + throw new Error(`Unknown data-loading action "${finalAction}"`); + } + + let isHandled = false; + directive.modifiers.forEach((modifier => { + switch (modifier.name) { + case 'delay': { + // if loading has *stopped*, the delay modifier has no effect + if (!isLoading) { + break; + } + + const delayLength = modifier.value || 200; + setTimeout(() => { + if (element.hasAttribute('data-live-is-loading')) { + loadingDirective(); + } + }, delayLength); + + isHandled = true; + + break; + } + default: + throw new Error(`Unknown modifier ${modifier.name} used in the loading directive ${directive.getString()}`) + } + })); + + // execute the loading directive + if(!isHandled) { + loadingDirective(); + } + } + + _getLoadingDirectives() { + const loadingDirectives = []; + + this.element.querySelectorAll('[data-loading]').forEach((element => { + // use "show" if the attribute is empty + const directives = parseDirectives(element.dataset.loading || 'show'); + + loadingDirectives.push({ + element, + directives, + }); + })); + + return loadingDirectives; + } + + _showElement(element) { + element.style.display = 'inline-block'; + } + + _hideElement(element) { + element.style.display = 'none'; + } + + _addClass(element, classes) { + element.classList.add(...combineSpacedArray(classes)); + } + + _removeClass(element, classes) { + element.classList.remove(...combineSpacedArray(classes)); + + // remove empty class="" to avoid morphdom "diff" problem + if (element.classList.length === 0) { + this._removeAttributes(element, ['class']); + } + } + + _addAttributes(element, attributes) { + attributes.forEach((attribute) => { + element.setAttribute(attribute, ''); + }) + } + + _removeAttributes(element, attributes) { + attributes.forEach((attribute) => { + element.removeAttribute(attribute); + }) + } + + _willDataFitInUrl() { + // if the URL gets remotely close to 2000 chars, it may not fit + return Object.values(this.dataValue).join(',').length < 1500; + } + + _executeMorphdom(newHtml) { + // https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro#answer-35385518 + function htmlToElement(html) { + const template = document.createElement('template'); + html = html.trim(); + template.innerHTML = html; + + return template.content.firstChild; + } + + const newElement = htmlToElement(newHtml); + morphdom(this.element, newElement, { + onBeforeElUpdated: (fromEl, toEl) => { + // https://github.com/patrick-steele-idem/morphdom#can-i-make-morphdom-blaze-through-the-dom-tree-even-faster-yes + if (fromEl.isEqualNode(toEl)) { + return false + } + + // avoid updating child components: they will handle themselves + if (fromEl.hasAttribute('data-controller') + && fromEl.getAttribute('data-controller').split(' ').indexOf('live') !== -1 + && fromEl !== this.element + ) { + return false; + } + + return true; + } + }); + } + + markAsWindowUnloaded = () => { + this.isWindowUnloaded = true; + }; + + _initiatePolling(rawPollConfig) { + const directives = parseDirectives(rawPollConfig || '$render'); + + directives.forEach((directive) => { + let duration = 2000; + + directive.modifiers.forEach((modifier) => { + switch (modifier.name) { + case 'delay': + if (modifier.value) { + duration = modifier.value; + } + + break; + default: + console.warn(`Unknown modifier "${modifier.name}" in data-poll "${rawPollConfig}".`); + } + }); + + this.startPoll(directive.action, duration); + }) + } + + startPoll(actionName, duration) { + let callback; + if (actionName.charAt(0) === '$') { + callback = () => { + this[actionName](); + } + } else { + callback = () => { + this._makeRequest(actionName); + } + } + + this.pollingIntervals.push(setInterval(() => { + callback(); + }, duration)); + } + + _dispatchEvent(name, payload = null, canBubble = true, cancelable = false) { + const userEvent = new CustomEvent(name, { + bubbles: canBubble, + cancelable, + detail: payload, + }); + + return this.element.dispatchEvent(userEvent); + } +} + +/** + * Tracks the current "re-render" promises. + */ +class PromiseStack { + stack = []; + + addPromise(promise) { + this.stack.push(promise); + } + + /** + * Removes the promise AND returns `true` if it is the most recent. + * + * @param {Promise} promise + * @return {boolean} + */ + removePromise(promise) { + const index = this.findPromiseIndex(promise); + + // promise was not found - it was removed because a new Promise + // already resolved before it + if (index === -1) { + return false; + } + + // "save" whether this is the most recent or not + const isMostRecent = this.stack.length === (index + 1); + + // remove all promises starting from the oldest up through this one + this.stack.splice(0, index + 1); + + return isMostRecent; + } + + findPromiseIndex(promise) { + return this.stack.findIndex((item) => item === promise); + } +} + +const parseLoadingAction = function(action, isLoading) { + switch (action) { + case 'show': + return isLoading ? 'show' : 'hide'; + case 'hide': + return isLoading ? 'hide' : 'show'; + case 'addClass': + return isLoading ? 'addClass' : 'removeClass'; + case 'removeClass': + return isLoading ? 'removeClass' : 'addClass'; + case 'addAttribute': + return isLoading ? 'addAttribute' : 'removeAttribute'; + case 'removeAttribute': + return isLoading ? 'removeAttribute' : 'addAttribute'; + } + + throw new Error(`Unknown data-loading action "${action}"`); +} diff --git a/src/LiveComponent/assets/src/polyfills.js b/src/LiveComponent/assets/src/polyfills.js new file mode 100644 index 00000000000..20b82451a00 --- /dev/null +++ b/src/LiveComponent/assets/src/polyfills.js @@ -0,0 +1,6 @@ +/** + * A file that imports all necessary polyfills. + * + * core-js is replaced by the actually-needed imports at build time. + */ +import 'core-js'; diff --git a/src/LiveComponent/assets/src/set_deep_data.js b/src/LiveComponent/assets/src/set_deep_data.js new file mode 100644 index 00000000000..43d2f265bfa --- /dev/null +++ b/src/LiveComponent/assets/src/set_deep_data.js @@ -0,0 +1,73 @@ +// post.user.username +export function setDeepData(data, propertyPath, value) { + // cheap way to deep clone simple data + const finalData = JSON.parse(JSON.stringify(data)); + + let currentLevelData = finalData; + const parts = propertyPath.split('.'); + + // change currentLevelData to the final depth object + for (let i = 0; i < parts.length - 1; i++) { + currentLevelData = currentLevelData[parts[i]]; + } + + // now finally change the key on that deeper object + const finalKey = parts[parts.length - 1]; + + // make sure the currentLevelData is an object, not a scalar + // if it is, it means the initial data didn't know that sub-properties + // could be exposed. Or, you're just trying to set some deep + // path - e.g. post.title - onto some property that is, for example, + // an integer (2). + if (typeof currentLevelData !== 'object') { + const lastPart = parts.pop(); + throw new Error(`Cannot set data-model="${propertyPath}". They parent "${parts.join(',')}" data does not appear to be an object (it's "${currentLevelData}"). Did you forget to add exposed={"${lastPart}"} to its LiveProp?`) + } + + // represents a situation where the key you're setting *is* an object, + // but the key we're setting is a new key. This, perhaps, could be + // allowed. But right now, all keys should be initialized with the + // initial data. + if (currentLevelData[finalKey] === undefined) { + const lastPart = parts.pop(); + if (parts.length > 0) { + console.warn(`The property used in data-model="${propertyPath}" was never initialized. Did you forget to add exposed={"${lastPart}"} to its LiveProp?`) + } else { + console.warn(`The property used in data-model="${propertyPath}" was never initialized. Did you forget to expose "${lastPart}" as a LiveProp?`) + } + } + + currentLevelData[finalKey] = value; + + return finalData; +} + +/** + * Checks if the given propertyPath is for a valid top-level key. + * + * @param {Object} data + * @param {string} propertyPath + * @return {boolean} + */ +export function doesDeepPropertyExist(data, propertyPath) { + const parts = propertyPath.split('.'); + + return data[parts[0]] !== undefined; +} + +/** + * Normalizes model names with [] into the "." syntax. + * + * For example: "user[firstName]" becomes "user.firstName" + * + * @param {string} model + * @return {string} + */ +export function normalizeModelName(model) { + return model + .split('[') + // ['object', 'foo', 'bar', 'ya'] + .map(function (s) { + return s.replace(']', '') + }).join('.') +} diff --git a/src/LiveComponent/assets/src/string_utils.js b/src/LiveComponent/assets/src/string_utils.js new file mode 100644 index 00000000000..8751c710701 --- /dev/null +++ b/src/LiveComponent/assets/src/string_utils.js @@ -0,0 +1,23 @@ +/** + * Splits each string in an array containing a space into an extra array item: + * + * Input: + * [ + * 'foo', + * 'bar baz', + * ] + * + * Output: + * ['foo', 'bar', 'baz'] + * + * @param {string[]} parts + * @return {string[]} + */ +export function combineSpacedArray(parts) { + const finalParts = []; + parts.forEach((part) => { + finalParts.push(...part.split(' ')) + }); + + return finalParts; +} diff --git a/src/LiveComponent/assets/styles/live.css b/src/LiveComponent/assets/styles/live.css new file mode 100644 index 00000000000..7f16a53d040 --- /dev/null +++ b/src/LiveComponent/assets/styles/live.css @@ -0,0 +1,3 @@ +[data-loading=""], [data-loading="show"], [data-loading="delay|show"] { + display: none; +} diff --git a/src/LiveComponent/assets/test/controller/action.test.js b/src/LiveComponent/assets/test/controller/action.test.js new file mode 100644 index 00000000000..0f4c7679c8d --- /dev/null +++ b/src/LiveComponent/assets/test/controller/action.test.js @@ -0,0 +1,71 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import { clearDOM } from '@symfony/stimulus-testing'; +import { startStimulus } from '../tools'; +import { getByLabelText, getByText, waitFor } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock-jest'; + +describe('LiveController Action Tests', () => { + const template = (data) => ` +
+ + + ${data.isSaved ? 'Comment Saved!' : ''} + + +
+ `; + + afterEach(() => { + clearDOM(); + fetchMock.reset(); + }); + + it('Sends an action and cancels any re-renders', async () => { + const data = { comments: 'hi' }; + const { element } = await startStimulus( + template(data), + data + ); + + // ONLY a post is sent, not a re-render GET + const postMock = fetchMock.postOnce('http://localhost/_components/my_component/save', { + html: template({ comments: 'hi weaver', isSaved: true }), + data: { comments: 'hi weaver', isSaved: true } + }); + + await userEvent.type(getByLabelText(element, 'Comments:'), ' WEAVER'); + + getByText(element, 'Save').click(); + + await waitFor(() => expect(element).toHaveTextContent('Comment Saved!')); + expect(getByLabelText(element, 'Comments:')).toHaveValue('hi weaver'); + + fetchMock.done(); + + expect(postMock.lastOptions().body.get('comments')).toEqual('hi WEAVER'); + }); +}); diff --git a/src/LiveComponent/assets/test/controller/basic.test.js b/src/LiveComponent/assets/test/controller/basic.test.js new file mode 100644 index 00000000000..80d695b592b --- /dev/null +++ b/src/LiveComponent/assets/test/controller/basic.test.js @@ -0,0 +1,37 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { startStimulus } from '../tools'; + +describe('LiveController Basic Tests', () => { + afterEach(() => { + clearDOM(); + }); + + it('dispatches connect event', async () => { + const container = mountDOM(''); + + let eventTriggered = false; + container.addEventListener('live:connect', () => { + eventTriggered = true; + }) + const { element } = await startStimulus( + '
', + {}, + container + ); + + // smoke test + expect(element).toHaveAttribute('data-controller', 'live'); + expect(eventTriggered).toStrictEqual(true); + }); +}); diff --git a/src/LiveComponent/assets/test/controller/csrf.test.js b/src/LiveComponent/assets/test/controller/csrf.test.js new file mode 100644 index 00000000000..4ee575179a4 --- /dev/null +++ b/src/LiveComponent/assets/test/controller/csrf.test.js @@ -0,0 +1,66 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import { clearDOM } from '@symfony/stimulus-testing'; +import { startStimulus } from '../tools'; +import { getByLabelText, getByText, waitFor } from '@testing-library/dom'; +import fetchMock from 'fetch-mock-jest'; + +describe('LiveController CSRF Tests', () => { + const template = (data) => ` +
+ + + ${data.isSaved ? 'Comment Saved!' : ''} + + +
+ `; + + afterEach(() => { + clearDOM(); + fetchMock.reset(); + }); + + it('Sends the CSRF token on an action', async () => { + const data = { comments: 'hi' }; + const { element } = await startStimulus( + template(data), + data + ); + + const postMock = fetchMock.postOnce('http://localhost/_components/my_component/save', { + html: template({ comments: 'hi', isSaved: true }), + data: { comments: 'hi', isSaved: true } + }); + getByText(element, 'Save').click(); + + await waitFor(() => expect(element).toHaveTextContent('Comment Saved!')); + + expect(postMock.lastOptions().headers['X-CSRF-TOKEN']).toEqual('123TOKEN'); + + fetchMock.done(); + }); +}); diff --git a/src/LiveComponent/assets/test/controller/model.test.js b/src/LiveComponent/assets/test/controller/model.test.js new file mode 100644 index 00000000000..b5256da3722 --- /dev/null +++ b/src/LiveComponent/assets/test/controller/model.test.js @@ -0,0 +1,245 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import { clearDOM } from '@symfony/stimulus-testing'; +import { startStimulus } from '../tools'; +import { getByLabelText, waitFor } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock-jest'; + +describe('LiveController data-model Tests', () => { + const template = (data) => ` +
+ +
+ `; + + afterEach(() => { + clearDOM(); + fetchMock.reset(); + }); + + it('renders correctly with data-model and live#update', async () => { + const data = { name: 'Ryan' }; + const { element, controller } = await startStimulus( + template(data), + data + ); + + fetchMock.getOnce('http://localhost/?name=Ryan+WEAVER', { + html: template({ name: 'Ryan Weaver' }), + data: { name: 'Ryan Weaver' } + }); + + await userEvent.type(getByLabelText(element, 'Name:'), ' WEAVER', { + // this tests the debounce: characters have a 10ms delay + // in between, but the debouncing prevents multiple calls + delay: 10 + }); + + await waitFor(() => expect(getByLabelText(element, 'Name:')).toHaveValue('Ryan Weaver')); + expect(controller.dataValue).toEqual({name: 'Ryan Weaver'}); + + // assert all calls were done the correct number of times + fetchMock.done(); + + // assert the input is still focused after rendering + expect(document.activeElement.dataset.model).toEqual('name'); + }); + + it('correctly only uses the most recent render call results', async () => { + const data = { name: 'Ryan' }; + const { element, controller } = await startStimulus( + template(data), + data + ); + + let renderCount = 0; + element.addEventListener('live:render', () => { + renderCount++; + }) + + const requests = [ + ['g', 650], + ['gu', 250], + ['guy', 150] + ]; + requests.forEach(([string, delay]) => { + fetchMock.getOnce(`http://localhost/?name=Ryan${string}`, { + // the _ at the end helps us look that the input has changed + // as a result of a re-render (not just from typing in the input) + html: template({ name: `Ryan${string}_` }), + data: { name: `Ryan${string}_` } + }, { delay }); + }); + + await userEvent.type(getByLabelText(element, 'Name:'), 'guy', { + // This will result in this sequence: + // A) "g" starts 200ms + // B) "gu" starts 400ms + // C) "guy" starts 600ms + // D) "gu" finishes 650ms (is ignored) + // E) "guy" finishes 750ms (is used) + // F) "g" finishes 850ms (is ignored) + delay: 200 + }); + + await waitFor(() => expect(getByLabelText(element, 'Name:')).toHaveValue('Ryanguy_')); + expect(controller.dataValue).toEqual({name: 'Ryanguy_'}); + + // assert all calls were done the correct number of times + fetchMock.done(); + + // only 1 render should have ultimately occurred + expect(renderCount).toEqual(1); + }); + + it('falls back to using the name attribute when no data-model is present', async () => { + const data = { name: 'Ryan' }; + const { element, controller } = await startStimulus( + template(data), + data + ); + + // replace data-model with name attribute + const inputElement = getByLabelText(element, 'Name:'); + delete inputElement.dataset.model; + inputElement.setAttribute('name', 'name'); + + fetchMock.getOnce('http://localhost/?name=Ryan+WEAVER', { + html: template({ name: 'Ryan Weaver' }), + data: { name: 'Ryan Weaver' } + }); + + await userEvent.type(inputElement, ' WEAVER'); + + await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver')); + expect(controller.dataValue).toEqual({name: 'Ryan Weaver'}); + + // assert all calls were done the correct number of times + fetchMock.done(); + }); + + it('standardizes user[firstName] style models into post.name', async () => { + const deeperModelTemplate = (data) => ` +
+ +
+ `; + const data = { user: { firstName: 'Ryan' } }; + const { element, controller } = await startStimulus( + deeperModelTemplate(data), + data + ); + + // replace data-model with name attribute + const inputElement = getByLabelText(element, 'First Name:'); + + const newData = { user: { firstName: 'Ryan Weaver' } }; + fetchMock.getOnce('http://localhost/?user%5BfirstName%5D=Ryan+WEAVER', { + html: deeperModelTemplate(newData), + data: newData + }); + + await userEvent.type(inputElement, ' WEAVER'); + + await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver')); + expect(controller.dataValue).toEqual({ user: { firstName: 'Ryan Weaver' } }); + + // assert all calls were done the correct number of times + fetchMock.done(); + }); + + it('updates correctly when live#update is on a parent element', async () => { + const parentUpdateTemplate = (data) => ` +
+
+ +
+
+ `; + + const data = { firstName: 'Ryan' }; + const { element, controller } = await startStimulus( + parentUpdateTemplate(data), + data + ); + + fetchMock.getOnce('http://localhost/?firstName=Ryan+WEAVER', { + html: parentUpdateTemplate({ firstName: 'Ryan Weaver' }), + data: { firstName: 'Ryan Weaver' } + }); + + const inputElement = getByLabelText(element, 'Name:'); + await userEvent.type(inputElement, ' WEAVER'); + + await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver')); + + fetchMock.done(); + + // assert the input is still focused after rendering + expect(document.activeElement.getAttribute('name')).toEqual('firstName'); + }); + + it('tracks which fields should be modified, sends, without forgetting previous fields', async () => { + // start with one other field in validatedFields + const data = { name: 'Ryan', validatedFields: ['otherField'] }; + const { element, controller } = await startStimulus( + template(data), + data + ); + + fetchMock.getOnce('http://localhost/?name=Ryan+WEAVER&validatedFields%5B0%5D=otherField&validatedFields%5B1%5D=name', { + html: template({ name: 'Ryan Weaver' }), + data: { name: 'Ryan Weaver' } + }); + + const inputElement = getByLabelText(element, 'Name:'); + await userEvent.type(inputElement, ' WEAVER'); + + await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver')); + + // assert all calls were done the correct number of times + fetchMock.done(); + }); + + // TODO - test changing debounce + // TODO - test deferred rendering +}); diff --git a/src/LiveComponent/assets/test/controller/render.test.js b/src/LiveComponent/assets/test/controller/render.test.js new file mode 100644 index 00000000000..9bb381c6f9f --- /dev/null +++ b/src/LiveComponent/assets/test/controller/render.test.js @@ -0,0 +1,183 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import { clearDOM } from '@symfony/stimulus-testing'; +import { startStimulus } from '../tools'; +import { createEvent, fireEvent, getByLabelText, getByText, waitFor } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock-jest'; + +describe('LiveController rendering Tests', () => { + const template = (data, includeLoading = false) => ` +
+ + + + Name: ${data.name}
+ + +
+ `; + + afterEach(() => { + clearDOM(); + fetchMock.reset(); + }); + + it('renders from the AJAX endpoint & updates data', async () => { + const data = { name: 'Ryan' }; + const { element, controller } = await startStimulus( + template(data), + data + ); + + fetchMock.get('http://localhost/_components/my_component?name=Ryan', { + html: '
aloha!
', + data: { name: 'Kevin' } + }); + getByText(element, 'Reload').click(); + + await waitFor(() => expect(element).toHaveTextContent('aloha!')); + expect(controller.dataValue).toEqual({name: 'Kevin'}); + }); + + it('conserves values of fields modified after a render request', async () => { + const data = { name: 'Ryan' }; + const { element } = await startStimulus( + template(data), + data + ); + + fetchMock.get('http://localhost/_components/my_component?name=Ryan', { + html: template({ name: 'Kevin' }), + data: { name: 'Kevin' } + }, { + delay: 100 + }); + getByText(element, 'Reload').click(); + userEvent.type(getByLabelText(element, 'Comments:'), '!!'); + + await waitFor(() => expect(element).toHaveTextContent('Name: Kevin')); + expect(getByLabelText(element, 'Comments:')).toHaveValue('i like pizza!!'); + expect(document.activeElement.name).toEqual('comments'); + }); + + it('conserves values of fields modified after render but with loading behavior', async () => { + const data = { name: 'Ryan' }; + const { element } = await startStimulus( + // "true" gives the comment input a loading behavior + // this could make the input.isEqualNode() be false when comparing + // that's exactly what we want test for + template(data, true), + data + ); + + fetchMock.get('http://localhost/_components/my_component?name=Ryan', { + html: template({ name: 'Kevin' }, true), + data: { name: 'Kevin' } + }, { + delay: 100 + }); + getByText(element, 'Reload').click(); + userEvent.type(getByLabelText(element, 'Comments:'), '!!'); + + await waitFor(() => expect(element).toHaveTextContent('Name: Kevin')); + expect(getByLabelText(element, 'Comments:')).toHaveValue('i like pizza!!'); + expect(document.activeElement.name).toEqual('comments'); + }); + + it('avoids updating a child component', async () => { + const parentTemplate = (data, childData) => { + return ` +
+ Title: ${data.title} + + + + ${template(childData)} +
+ ` + } + + const data = { title: 'Parent component' }; + const childData = { name: 'Ryan' }; + const { element } = await startStimulus( + // render the parent and child component + parentTemplate(data, childData), + data + ); + // setup the values on the child element + element.querySelector('[data-controller="live"]').dataset.liveDataValue = JSON.stringify(childData); + + // child re-render: render with new name & an error class + fetchMock.get('http://localhost/_components/my_component?name=Ryan', { + html: template({ name: 'Kevin', hasError: true }), + data: { name: 'Kevin', hasError: true } + }); + + // reload the child template + getByText(element, 'Reload').click(); + await waitFor(() => expect(element).toHaveTextContent('Name: Kevin')); + + // reload the parent template + fetchMock.get('http://localhost/_components/parent?title=Parent+component', { + html: parentTemplate({ title: 'Changed parent' }, { name: 'changed name'}), + data: { title: 'Changed parent'} + }); + getByText(element, 'Parent Re-render').click(); + await waitFor(() => expect(element).toHaveTextContent('Title: Changed parent')); + + // the child component should *not* have updated + expect(element).toHaveTextContent('Name: Kevin'); + }); + + it('cancels a re-render if the page is navigating away', async () => { + const data = { name: 'Ryan' }; + const { element, controller } = await startStimulus( + template(data), + data + ); + + fetchMock.get('http://localhost/_components/my_component?name=Ryan', { + html: '
aloha!
', + data: { name: 'Kevin' } + }, { + delay: 100 + }); + + getByText(element, 'Reload').click(); + // imitate navigating away + fireEvent(window, createEvent('beforeunload', window)); + + // wait for the fetch to fonish + await fetchMock.flush(); + + // the re-render should not have happened + expect(element).not.toHaveTextContent('aloha!'); + }); +}); diff --git a/src/LiveComponent/assets/test/directives_parser.test.js b/src/LiveComponent/assets/test/directives_parser.test.js new file mode 100644 index 00000000000..f15997fd8b9 --- /dev/null +++ b/src/LiveComponent/assets/test/directives_parser.test.js @@ -0,0 +1,229 @@ +import { parseDirectives } from '../dist/directives_parser'; + +const assertDirectiveEquals = function(actual, expected) { + delete actual.getString; + + expect(actual).toEqual(expected); +} + +describe('directives parser', () => { + it('parses no attribute value', () => { + // (no attribute value) + const directives = parseDirectives(null); + expect(directives).toHaveLength(0); + }); + + it('parses an empty attribute', () => { + // + const directives = parseDirectives(''); + expect(directives).toHaveLength(0); + }); + + it('parses a simple action', () => { + // data-loading="hide" + const directives = parseDirectives('hide'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'hide', + args: [], + named: {}, + modifiers: [], + }) + }); + + it('parses an action with a simple argument', () => { + const directives = parseDirectives('addClass(opacity-50)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'addClass', + args: ['opacity-50'], + named: {}, + modifiers: [], + }) + }); + + it('parses an action with one argument with a space', () => { + const directives = parseDirectives('addClass(opacity-50 disabled)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'addClass', + args: ['opacity-50 disabled'], + named: {}, + modifiers: [], + }) + }); + + it('parses an action with multiple, unnamed arguments', () => { + const directives = parseDirectives('addClass(opacity-50, disabled)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'addClass', + // space between arguments is trimmed + args: ['opacity-50', 'disabled'], + named: {}, + modifiers: [], + }) + }); + + it('parses multiple actions simple', () => { + const directives = parseDirectives('addClass(opacity-50) addAttribute(disabled)'); + expect(directives).toHaveLength(2); + assertDirectiveEquals(directives[0], { + action: 'addClass', + args: ['opacity-50'], + named: {}, + modifiers: [], + }) + assertDirectiveEquals(directives[1], { + action: 'addAttribute', + args: ['disabled'], + named: {}, + modifiers: [], + }) + }); + + it('parses multiple actions with multiple arguments', () => { + const directives = parseDirectives('hide addClass(opacity-50 disabled) addAttribute(disabled)'); + expect(directives).toHaveLength(3); + assertDirectiveEquals(directives[0], { + action: 'hide', + args: [], + named: {}, + modifiers: [], + }) + assertDirectiveEquals(directives[1], { + action: 'addClass', + args: ['opacity-50 disabled'], + named: {}, + modifiers: [], + }) + assertDirectiveEquals(directives[2], { + action: 'addAttribute', + args: ['disabled'], + named: {}, + modifiers: [], + }) + }); + + it('parses single named argument', () => { + const directives = parseDirectives('save(foo=bar)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'save', + args: [], + named: { foo: 'bar' }, + modifiers: [], + }) + }); + + it('parses multiple named arguments', () => { + const directives = parseDirectives('save(foo=bar, baz=bazzles)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'save', + args: [], + named: { foo: 'bar', baz: 'bazzles' }, + modifiers: [], + }) + }); + + it('parses arguments and spaces are kept', () => { + const directives = parseDirectives('save(foo= bar)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'save', + args: [], + named: { foo: ' bar' }, + modifiers: [], + }) + }); + + it('parses argument names with space is trimmed', () => { + const directives = parseDirectives('save(foo =bar)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'save', + args: [], + named: { foo: 'bar' }, + modifiers: [], + }) + }); + + it('parses simple modifiers', () => { + const directives = parseDirectives('delay|addClass(disabled)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'addClass', + args: ['disabled'], + named: {}, + modifiers: [ + { name: 'delay', value: null } + ], + }) + }); + + it('parses modifiers with argument', () => { + const directives = parseDirectives('delay(400)|addClass(disabled)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'addClass', + args: ['disabled'], + named: {}, + modifiers: [ + { name: 'delay', value: '400' }, + ], + }) + }); + + it('parses multiple modifiers', () => { + const directives = parseDirectives('prevent|debounce(400)|save(foo=bar)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'save', + args: [], + named: { foo: 'bar' }, + modifiers: [ + { name: 'prevent', value: null }, + { name: 'debounce', value: '400' }, + ], + }) + }); + + describe('errors on syntax errors', () => { + it('missing ending )', () => { + expect(() => { + parseDirectives('addClass(opacity-50'); + }).toThrow('Did you forget to add a closing ")" after "addClass"?') + }); + + it('missing ending before next action', () => { + expect(() => { + parseDirectives('addClass(opacity-50 hide'); + }).toThrow('Did you forget to add a closing ")" after "addClass"?') + }); + + it('no space between actions', () => { + expect(() => { + parseDirectives('addClass(opacity-50)hide'); + }).toThrow('Missing space after addClass()') + }); + + it('named and unnamed arguments cannot be mixed', () => { + expect(() => { + parseDirectives('save(foo=bar, baz)'); + }).toThrow('Normal and named arguments cannot be mixed inside "save()"') + }); + + it('modifier cannot have multiple arguments', () => { + expect(() => { + parseDirectives('debounce(10, 20)|save'); + }).toThrow('The modifier "debounce()" does not support multiple arguments.') + }); + + it('modifier cannot have multiple arguments', () => { + expect(() => { + parseDirectives('debounce(foo=bar)|save'); + }).toThrow('The modifier "debounce()" does not support named arguments.') + }); + }); +}); diff --git a/src/LiveComponent/assets/test/set_deep_data.test.js b/src/LiveComponent/assets/test/set_deep_data.test.js new file mode 100644 index 00000000000..515086ab5b2 --- /dev/null +++ b/src/LiveComponent/assets/test/set_deep_data.test.js @@ -0,0 +1,99 @@ +import { setDeepData, normalizeModelName } from '../dist/set_deep_data'; + +describe('setDeepData', () => { + it('sets a simple key', () => { + const data = { + message: 'original', + isPublished: true, + } + const finalData = setDeepData(data, 'message', 'new_message'); + expect(finalData.message).toEqual('new_message'); + expect(finalData.isPublished).toEqual(true); + // original is not modified + expect(data.message).toEqual('original'); + }); + + it('sets a deeper key', () => { + const data = { + post: { + message: 'original', + isPublished: true, + }, + other_field: 'another field' + } + const finalData = setDeepData(data, 'post.message', 'new_message'); + expect(finalData.post.message).toEqual('new_message'); + expect(finalData.post.isPublished).toEqual(true); + // original is not modified + expect(data).toEqual({ + post: { + message: 'original', + isPublished: true, + }, + other_field: 'another field' + }); + }); + + it('sets a very deep key', () => { + const data = { + post: { + user: { + username: 'weaverryan', + favoriteColor: 'pink', + }, + isPublished: true, + }, + other_field: 'another field' + } + const finalData = setDeepData(data, 'post.user.favoriteColor', 'orange'); + expect(finalData).toEqual({ + post: { + user: { + username: 'weaverryan', + favoriteColor: 'orange', + }, + isPublished: true, + }, + other_field: 'another field' + }); + expect(data).toEqual({ + post: { + user: { + username: 'weaverryan', + favoriteColor: 'pink', + }, + isPublished: true, + }, + other_field: 'another field' + }); + }); + + it('sets an array key while modifying references', () => { + const data = { + post: { + message: 'original', + isPublished: true, + }, + validatedFields: [] + } + let validatedFields = ['foo']; + let finalData = setDeepData(data, 'post.message', 'updated'); + finalData = setDeepData(finalData, 'validatedFields', validatedFields); + finalData = setDeepData(finalData, 'post.message', 'updated again'); + validatedFields.push('bar'); + finalData = setDeepData(finalData, 'validatedFields', validatedFields); + expect(finalData.validatedFields).toEqual(['foo', 'bar']); + }); + + // sets undefined keys, even recursively +}); + +describe('normalizeModelName', () => { + it('can normalize a boring string', () => { + expect(normalizeModelName('firstName')).toEqual('firstName'); + }); + + it('can normalize a string with []', () => { + expect(normalizeModelName('user[firstName]')).toEqual('user.firstName'); + }); +}); diff --git a/src/LiveComponent/assets/test/setup.js b/src/LiveComponent/assets/test/setup.js new file mode 100644 index 00000000000..9461a668e3c --- /dev/null +++ b/src/LiveComponent/assets/test/setup.js @@ -0,0 +1,12 @@ +/* + * This file is part of the Symfony Live Component package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import '@symfony/stimulus-testing/setup'; +// adds the missing "fetch" function - fetch-mock-jest will replace this +global.fetch = require('node-fetch'); diff --git a/src/LiveComponent/assets/test/string_utils.test.js b/src/LiveComponent/assets/test/string_utils.test.js new file mode 100644 index 00000000000..22febd920ca --- /dev/null +++ b/src/LiveComponent/assets/test/string_utils.test.js @@ -0,0 +1,13 @@ +import { combineSpacedArray } from '../dist/string_utils'; + +describe('combinedSpacedArray', () => { + it('parse normal array', () => { + const items = combineSpacedArray(['hidden', 'other']); + expect(items).toEqual(['hidden', 'other']); + }); + + it('parse array with spaced item', () => { + const items = combineSpacedArray(['hidden other', 'bar']); + expect(items).toEqual(['hidden', 'other', 'bar']); + }); +}); diff --git a/src/LiveComponent/assets/test/tools.js b/src/LiveComponent/assets/test/tools.js new file mode 100644 index 00000000000..088a87f297d --- /dev/null +++ b/src/LiveComponent/assets/test/tools.js @@ -0,0 +1,41 @@ +import { Application } from 'stimulus'; +import LiveController from '../dist/live_controller'; +import { waitFor } from '@testing-library/dom'; + +const TestData = class { + constructor(controller, element) { + this.controller = controller; + this.element = element; + } +} + +let application; + +const startStimulus = async (html, data, container = null) => { + // start the Stimulus app just once per test suite + if (!application) { + application = Application.start(); + application.register('live', LiveController); + } + + if (!container) { + container = document.createElement('div'); + } + + container.innerHTML = html; + document.body.appendChild(container); + + const element = getControllerElement(container); + element.dataset.liveDataValue = JSON.stringify(data); + + await waitFor(() => application.getControllerForElementAndIdentifier(element, 'live')); + const controller = application.getControllerForElementAndIdentifier(element, 'live'); + + return new TestData(controller, element); +}; + +const getControllerElement = (container) => { + return container.querySelector('[data-controller="live"]'); +}; + +export { startStimulus, getControllerElement }; diff --git a/src/LiveComponent/composer.json b/src/LiveComponent/composer.json new file mode 100644 index 00000000000..ce3a8926c28 --- /dev/null +++ b/src/LiveComponent/composer.json @@ -0,0 +1,55 @@ +{ + "name": "symfony/ux-live-component", + "type": "symfony-bundle", + "description": "Live components for Symfony", + "keywords": [ + "symfony-ux", + "twig", + "components" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\LiveComponent\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\LiveComponent\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=7.4", + "symfony/ux-twig-component": "^1.4" + }, + "require-dev": { + "symfony/framework-bundle": "^4.4|^5.0", + "symfony/dependency-injection": "^4.4.2|^5.0", + "symfony/twig-bundle": "^4.4|^5.0", + "symfony/security-csrf": "^4.4|^5.0", + "symfony/serializer": "^4.4|^5.0", + "symfony/validator": "^4.4|^5.0", + "symfony/phpunit-bridge": "^5.2", + "doctrine/doctrine-bundle": "^2.0", + "doctrine/orm": "^2.7", + "zenstruck/foundry": "^1.10", + "zenstruck/browser": "^0.5.0" + }, + "extra": { + "branch-alias": { + "dev-main": "1.4-dev" + }, + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/src/LiveComponent/phpunit.xml.dist b/src/LiveComponent/phpunit.xml.dist new file mode 100644 index 00000000000..c86748b796a --- /dev/null +++ b/src/LiveComponent/phpunit.xml.dist @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + ./tests/ + + + + + + ./src + + + + + + + diff --git a/src/LiveComponent/src/Attribute/BeforeReRender.php b/src/LiveComponent/src/Attribute/BeforeReRender.php new file mode 100644 index 00000000000..9709231bc27 --- /dev/null +++ b/src/LiveComponent/src/Attribute/BeforeReRender.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Attribute; + +/** + * Call a method before re-rendering. + * + * This hook ONLY happens when rendering via HTTP: it does + * not happen during the initial render of a component. + * + * @Annotation + * @Target("METHOD") + * + * @experimental + */ +final class BeforeReRender +{ +} diff --git a/src/LiveComponent/src/Attribute/LiveAction.php b/src/LiveComponent/src/Attribute/LiveAction.php new file mode 100644 index 00000000000..01986ebf6ee --- /dev/null +++ b/src/LiveComponent/src/Attribute/LiveAction.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Attribute; + +/** + * @Annotation + * @Target("METHOD") + * + * @experimental + */ +final class LiveAction +{ +} diff --git a/src/LiveComponent/src/Attribute/LiveProp.php b/src/LiveComponent/src/Attribute/LiveProp.php new file mode 100644 index 00000000000..01af54801f1 --- /dev/null +++ b/src/LiveComponent/src/Attribute/LiveProp.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Attribute; + +use Symfony\UX\LiveComponent\LiveComponentInterface; + +/** + * @Annotation + * @Target("PROPERTY") + * + * @experimental + */ +final class LiveProp +{ + /** @var bool */ + private $writable = false; + + /** @var string[] */ + private $exposed = []; + + /** @var string|null */ + private $hydrateWith = null; + + /** @var string|null */ + private $dehydrateWith = null; + + /** + *The "frontend" field name that should be used for this property. + * + * This can be used, for example, to have a property called "foo", which actually + * maps to a frontend data model called "bar". + * + * If you pass a string that ends in () - like "getFieldName()" - that + * method on the component will be called to determine this. + * + * @var string|null + */ + private $fieldName = null; + + public function __construct(array $values) + { + $validOptions = ['writable', 'exposed', 'hydrateWith', 'dehydrateWith', 'fieldName']; + + foreach ($values as $name => $value) { + if (!\in_array($name, $validOptions)) { + throw new \InvalidArgumentException(sprintf('Unknown option "%s" passed to LiveProp. Valid options are: %s.', $name, implode(', ', $validOptions))); + } + + $this->$name = $value; + } + } + + public function isReadonly(): bool + { + return !$this->writable; + } + + public function exposed(): array + { + return $this->exposed; + } + + public function hydrateMethod(): ?string + { + return $this->hydrateWith ? trim($this->hydrateWith, '()') : null; + } + + public function dehydrateMethod(): ?string + { + return $this->dehydrateWith ? trim($this->dehydrateWith, '()') : null; + } + + public function calculateFieldName(LiveComponentInterface $component, string $fallback): string + { + if (!$this->fieldName) { + return $fallback; + } + + if (str_ends_with($this->fieldName, '()')) { + return $component->{trim($this->fieldName, '()')}(); + } + + return $this->fieldName; + } +} diff --git a/src/LiveComponent/src/Attribute/PostHydrate.php b/src/LiveComponent/src/Attribute/PostHydrate.php new file mode 100644 index 00000000000..6f6da92fb5a --- /dev/null +++ b/src/LiveComponent/src/Attribute/PostHydrate.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Attribute; + +/** + * @Annotation + * @Target("METHOD") + * + * @experimental + */ +final class PostHydrate +{ +} diff --git a/src/LiveComponent/src/Attribute/PreDehydrate.php b/src/LiveComponent/src/Attribute/PreDehydrate.php new file mode 100644 index 00000000000..254a60b9fb1 --- /dev/null +++ b/src/LiveComponent/src/Attribute/PreDehydrate.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Attribute; + +/** + * @Annotation + * @Target("METHOD") + * + * @experimental + */ +final class PreDehydrate +{ +} diff --git a/src/LiveComponent/src/ComponentValidator.php b/src/LiveComponent/src/ComponentValidator.php new file mode 100644 index 00000000000..7301ce06202 --- /dev/null +++ b/src/LiveComponent/src/ComponentValidator.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent; + +use Psr\Container\ContainerInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Contracts\Service\ServiceSubscriberInterface; + +/** + * @author Ryan Weaver + * + * @experimental + * + * @internal + */ +class ComponentValidator implements ComponentValidatorInterface, ServiceSubscriberInterface +{ + /** @var ContainerInterface */ + private $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + /** + * @return ConstraintViolation[][] + */ + public function validate(object $component): array + { + $errors = $this->getValidator()->validate($component); + + $validationErrors = []; + foreach ($errors as $error) { + /** @var ConstraintViolation $error */ + $property = $error->getPropertyPath(); + if (!isset($validationErrors[$property])) { + $validationErrors[$property] = []; + } + + $validationErrors[$property][] = $error; + } + + return $validationErrors; + } + + /** + * Validates a single field. + * + * If a property path - like post.title - is passed, this will + * validate the *entire* "post" property. It will then loop + * over all the errors and collect only those for "post.title". + * + * @return ConstraintViolation[] + */ + public function validateField(object $component, string $propertyPath): array + { + $propertyParts = explode('.', $propertyPath); + $propertyName = $propertyParts[0]; + + /** @var $errors */ + $errors = $this->getValidator()->validateProperty($component, $propertyName); + + $errorsForPath = []; + foreach ($errors as $error) { + /** @var ConstraintViolation $error */ + if ($error->getPropertyPath() === $propertyPath) { + $errorsForPath[] = $error; + } + } + + return $errorsForPath; + } + + private function getValidator(): ValidatorInterface + { + return $this->container->get('validator'); + } + + private function getPropertyAccessor(): PropertyAccessorInterface + { + return $this->container->get('property_accessor'); + } + + public static function getSubscribedServices(): array + { + return [ + 'validator' => ValidatorInterface::class, + 'property_accessor' => PropertyAccessorInterface::class, + ]; + } +} diff --git a/src/LiveComponent/src/ComponentValidatorInterface.php b/src/LiveComponent/src/ComponentValidatorInterface.php new file mode 100644 index 00000000000..9c4bd688345 --- /dev/null +++ b/src/LiveComponent/src/ComponentValidatorInterface.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent; + +use Symfony\Component\Validator\ConstraintViolation; + +/** + * @author Ryan Weaver + * + * @experimental + */ +interface ComponentValidatorInterface +{ + /** + * Returns an array - keyed by the property path - containing + * another array of validation errors. + * + * For example: + * + * [ + * 'firstName' => [ConstraintViolation, ConstraintViolation], + * ] + * + * @return ConstraintViolation[][] + */ + public function validate(object $component): array; + + /** + * Returns an array of violations for this one specific property. + * + * @return ConstraintViolation[] + */ + public function validateField(object $component, string $propertyName): array; +} diff --git a/src/LiveComponent/src/ComponentWithFormTrait.php b/src/LiveComponent/src/ComponentWithFormTrait.php new file mode 100644 index 00000000000..50ee34729b0 --- /dev/null +++ b/src/LiveComponent/src/ComponentWithFormTrait.php @@ -0,0 +1,214 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent; + +use Symfony\Component\Form\Form; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Symfony\UX\LiveComponent\Attribute\BeforeReRender; +use Symfony\UX\LiveComponent\Attribute\LiveProp; + +/** + * @author Ryan Weaver + * + * @experimental + */ +trait ComponentWithFormTrait +{ + private ?FormView $formView = null; + private ?FormInterface $formInstance = null; + + /** + * Holds the name prefix the form uses. + * + * @LiveProp() + */ + public ?string $formName = null; + + /** + * Holds the raw form values. + * + * @LiveProp(writable=true, fieldName="getFormName()") + */ + public ?array $formValues = null; + + /** + * Tracks whether this entire component has been validated. + * + * This is used to know if validation should be automatically applied + * when rendering. + * + * @LiveProp(writable=true) + * + * @var bool + */ + public $isValidated = false; + + /** + * Tracks which specific fields have been validated. + * + * Instead of validating the entire object (isValidated), + * the component can be validated, field-by-field. + * + * @LiveProp(writable=true) + * + * @var array + */ + public $validatedFields = []; + + /** + * Return the full, top-level, Form object that this component uses. + */ + abstract protected function instantiateForm(): FormInterface; + + /** + * Override in your class if you need extra mounted values. + * + * Call $this->setForm($form) manually in that situation + * if you're passing in an initial form. + */ + public function mount(?FormView $form = null) + { + if ($form) { + $this->setForm($form); + } + } + + /** + * Make sure the form has been submitted. + * + * This primarily applies to a re-render where $actionName is null. + * But, in the event that there is an action and the form was + * not submitted manually, it will be submitted here. + * + * @BeforeReRender() + */ + public function submitFormOnRender() + { + if (!$this->getFormInstance()->isSubmitted()) { + $this->submitForm(false); + } + } + + /** + * Returns the FormView object: useful for rendering your form/fields! + */ + public function getForm(): FormView + { + if (null === $this->formView) { + $this->formView = $this->getFormInstance()->createView(); + } + + return $this->formView; + } + + /** + * Call this from mount() if your component receives a FormView. + * + * If your are not passing a FormView into your component, you + * don't need to call this directly: the form will be set for + * you from your instantiateForm() method. + */ + public function setForm(FormView $form) + { + $this->formView = $form; + } + + public function getFormName(): string + { + if (!$this->formName) { + $this->formName = $this->getForm()->vars['name']; + } + + return $this->formName; + } + + public function getFormValues(): array + { + if (null === $this->formValues) { + $this->formValues = $this->extractFormValues($this->getForm()); + } + + return $this->formValues; + } + + private function submitForm(bool $validateAll = true) + { + $this->getFormInstance()->submit($this->formValues); + + if ($validateAll) { + // mark the entire component as validated + $this->isValidated = true; + // set fields back to empty, as now the *entire* object is validated. + $this->validatedFields = []; + } else { + // we only want to validate fields in validatedFields + // but really, everything is validated at this point, which + // means we need to clear validation on non-matching fields + $this->clearErrorsForNonValidatedFields($this->getFormInstance(), $this->getFormName()); + } + + if (!$this->getFormInstance()->isValid()) { + throw new UnprocessableEntityHttpException('Form validation failed in component'); + } + } + + /** + * Returns a hierarchical array of the entire form's values. + * + * This is used to pass the initial values into the live component's + * frontend, and it's meant to equal the raw POST data that would + * be sent if the form were submitted without modification. + */ + private function extractFormValues(FormView $formView): array + { + $values = []; + foreach ($formView->children as $child) { + $name = $child->vars['name']; + if (\count($child->children) > 0) { + $values[$name] = $this->extractFormValues($child); + + continue; + } + + if (\array_key_exists('checked', $child->vars)) { + // special handling for check boxes + $values[$name] = $child->vars['checked'] ? $child->vars['value'] : null; + } else { + $values[$name] = $child->vars['value']; + } + } + + return $values; + } + + private function getFormInstance(): FormInterface + { + if (null === $this->formInstance) { + $this->formInstance = $this->instantiateForm(); + } + + return $this->formInstance; + } + + private function clearErrorsForNonValidatedFields(Form $form, $currentPath = '') + { + if (!$currentPath || !\in_array($currentPath, $this->validatedFields, true)) { + $form->clearErrors(); + } + + foreach ($form as $name => $child) { + $this->clearErrorsForNonValidatedFields($child, sprintf('%s.%s', $currentPath, $name)); + } + } +} diff --git a/src/LiveComponent/src/DefaultComponentController.php b/src/LiveComponent/src/DefaultComponentController.php new file mode 100644 index 00000000000..6ac9097208f --- /dev/null +++ b/src/LiveComponent/src/DefaultComponentController.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent; + +use Symfony\UX\TwigComponent\ComponentInterface; + +/** + * @author Kevin Bond + * + * @experimental + * + * @internal + */ +final class DefaultComponentController +{ + /** @var LiveComponentInterface */ + private $component; + + public function __construct(ComponentInterface $component) + { + if (!$component instanceof LiveComponentInterface) { + throw new \InvalidArgumentException('Not an instance of LiveComponentInterface.'); + } + + $this->component = $component; + } + + public function __invoke(): void + { + } + + public function getComponent(): LiveComponentInterface + { + return $this->component; + } +} diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php new file mode 100644 index 00000000000..797c706d073 --- /dev/null +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -0,0 +1,81 @@ + + */ +final class LiveComponentExtension extends Extension +{ + public function load(array $configs, ContainerBuilder $container): void + { + $container->registerForAutoconfiguration(LiveComponentInterface::class) + ->addTag('controller.service_arguments') + ; + + $container->registerForAutoconfiguration(PropertyHydratorInterface::class) + ->addTag('twig.component.property_hydrator') + ; + + $container->register(DoctrineEntityPropertyHydrator::class) + ->setArguments([[new Reference('doctrine')]]) + ->addTag('twig.component.property_hydrator', ['priority' => -200]) + ; + + $container->register('twig.component.datetime_property_hydrator', NormalizerBridgePropertyHydrator::class) + ->setArguments([new Reference('serializer.normalizer.datetime')]) + ->addTag('twig.component.property_hydrator', ['priority' => -100]) + ; + + $container->register(LiveComponentHydrator::class) + ->setArguments([ + new TaggedIteratorArgument('twig.component.property_hydrator'), + new Reference('property_accessor'), + new Reference('annotation_reader'), + '%kernel.secret%', + ]) + ; + + $container->register(LiveComponentSubscriber::class) + ->addTag('kernel.event_subscriber') + ->addTag('container.service_subscriber') + ; + + $container->register(LiveComponentTwigExtension::class) + ->addTag('twig.extension') + ; + + $container->register(LiveComponentRuntime::class) + ->setArguments([ + new Reference(LiveComponentHydrator::class), + new Reference(UrlGeneratorInterface::class), + new Reference(CsrfTokenManagerInterface::class, ContainerBuilder::NULL_ON_INVALID_REFERENCE), + ]) + ->addTag('twig.runtime') + ; + + $container->register(ComponentValidator::class) + ->addTag('container.service_subscriber', ['key' => 'validator', 'id' => 'validator']) + ->addTag('container.service_subscriber', ['key' => 'property_accessor', 'id' => 'property_accessor']) + ; + + $container->setAlias(ComponentValidatorInterface::class, ComponentValidator::class); + } +} diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php new file mode 100644 index 00000000000..25439131435 --- /dev/null +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -0,0 +1,271 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\EventListener; + +use Doctrine\Common\Annotations\Reader; +use Psr\Container\ContainerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ControllerEvent; +use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Contracts\Service\ServiceSubscriberInterface; +use Symfony\UX\LiveComponent\Attribute\BeforeReRender; +use Symfony\UX\LiveComponent\DefaultComponentController; +use Symfony\UX\LiveComponent\LiveComponentHydrator; +use Symfony\UX\LiveComponent\LiveComponentInterface; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentRenderer; + +/** + * @author Kevin Bond + * @author Ryan Weaver + * + * @experimental + */ +class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscriberInterface +{ + private const JSON_FORMAT = 'live-component-json'; + private const JSON_CONTENT_TYPE = 'application/vnd.live-component+json'; + + /** @var ContainerInterface */ + private $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + public static function getSubscribedServices(): array + { + return [ + ComponentFactory::class, + ComponentRenderer::class, + LiveComponentHydrator::class, + Reader::class, + '?'.CsrfTokenManagerInterface::class, + ]; + } + + public function onKernelRequest(RequestEvent $event) + { + $request = $event->getRequest(); + if (!$this->isLiveComponentRequest($request)) { + return; + } + + $request->setFormat(self::JSON_FORMAT, self::JSON_CONTENT_TYPE); + + // the default "action" is get, which does nothing + $action = $request->get('action', 'get'); + $componentName = (string) $request->get('component'); + + if ('get' === $action) { + // set default controller for "default" action + $request->attributes->set( + '_controller', + new DefaultComponentController($this->container->get(ComponentFactory::class)->get($componentName)) + ); + + return; + } + + if (!$request->isMethod('post')) { + throw new MethodNotAllowedHttpException(['POST']); + } + + if ( + $this->container->has(CsrfTokenManagerInterface::class) && + !$this->container->get(CsrfTokenManagerInterface::class)->isTokenValid(new CsrfToken($componentName, $request->headers->get('X-CSRF-TOKEN')))) { + throw new BadRequestHttpException('Invalid CSRF token.'); + } + + try { + $componentServiceId = $this->container->get(ComponentFactory::class)->serviceIdFor($componentName); + } catch (\InvalidArgumentException $e) { + throw new NotFoundHttpException('Component not found.'); + } + + $request->attributes->set('_controller', sprintf('%s::%s', $componentServiceId, $action)); + } + + public function onKernelController(ControllerEvent $event) + { + $request = $event->getRequest(); + + if (!$this->isLiveComponentRequest($request)) { + return; + } + + $data = array_merge( + $request->query->all(), + $request->request->all() + ); + + $component = $event->getController(); + $action = null; + + if (\is_array($component)) { + // action is being called + $action = $component[1]; + $component = $component[0]; + } + + if ($component instanceof DefaultComponentController) { + $component = $component->getComponent(); + } + + if (!$component instanceof LiveComponentInterface) { + throw new NotFoundHttpException(sprintf('A request has been made for a component, but the component - "%s" does not implement LiveComponentInterface.', \get_class($component))); + } + + if (null !== $action && !$this->container->get(LiveComponentHydrator::class)->isActionAllowed($component, $action)) { + throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveProp attribute/annotation above it.', $action, \get_class($component))); + } + + $this->container->get(LiveComponentHydrator::class)->hydrate($component, $data); + + // extra variables to be made available to the controller + // (for "actions" only) + parse_str($request->query->get('values'), $values); + $request->attributes->add($values); + $request->attributes->set('_component', $component); + } + + public function onKernelView(ViewEvent $event) + { + $request = $event->getRequest(); + if (!$this->isLiveComponentRequest($request)) { + return; + } + + /** @var LiveComponentInterface $component */ + $component = $request->attributes->get('_component'); + + if (!$component instanceof LiveComponentInterface) { + throw new \InvalidArgumentException('Somehow we are missing the _component attribute'); + } + + $response = $this->createResponse($component, $request); + + $event->setResponse($response); + } + + public function onKernelException(ExceptionEvent $event) + { + $request = $event->getRequest(); + if (!$this->isLiveComponentRequest($request)) { + return; + } + + if (!$event->getThrowable() instanceof UnprocessableEntityHttpException) { + return; + } + + $component = $request->attributes->get('_component'); + + // in case the exception was too early somehow + if (!$component) { + return; + } + + $response = $this->createResponse($component, $request); + $event->setResponse($response); + } + + public function onKernelResponse(ResponseEvent $event): void + { + $request = $event->getRequest(); + $response = $event->getResponse(); + + if (!$this->isLiveComponentRequest($request)) { + return; + } + + if (!$this->isLiveComponentJsonRequest($request)) { + return; + } + + if (!$response->isRedirection()) { + return; + } + + $event->setResponse(new JsonResponse([ + 'redirect_url' => $response->headers->get('Location'), + ])); + } + + public static function getSubscribedEvents() + { + return [ + RequestEvent::class => 'onKernelRequest', + ControllerEvent::class => 'onKernelController', + ViewEvent::class => 'onKernelView', + ResponseEvent::class => 'onKernelResponse', + ExceptionEvent::class => 'onKernelException', + ]; + } + + private function createResponse(LiveComponentInterface $component, Request $request): Response + { + foreach ($this->beforeReRenderMethods($component) as $method) { + $component->{$method->name}(); + } + + $html = $this->container->get(ComponentRenderer::class)->render($component); + + if ($this->isLiveComponentJsonRequest($request)) { + return new JsonResponse( + [ + 'html' => $html, + 'data' => $this->container->get(LiveComponentHydrator::class)->dehydrate($component), + ], + 200, + ['Content-Type' => self::JSON_CONTENT_TYPE] + ); + } + + return new Response($html); + } + + private function isLiveComponentRequest(Request $request): bool + { + return 'live_component' === $request->attributes->get('_route'); + } + + private function isLiveComponentJsonRequest(Request $request): bool + { + return \in_array($request->getPreferredFormat(), [self::JSON_FORMAT, 'json'], true); + } + + /** + * @return \ReflectionMethod[] + */ + private function beforeReRenderMethods(LiveComponentInterface $component): iterable + { + foreach ((new \ReflectionClass($component))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($this->container->get(Reader::class)->getMethodAnnotation($method, BeforeReRender::class)) { + yield $method; + } + } + } +} diff --git a/src/LiveComponent/src/Exception/UnsupportedHydrationException.php b/src/LiveComponent/src/Exception/UnsupportedHydrationException.php new file mode 100644 index 00000000000..767f0c7fef8 --- /dev/null +++ b/src/LiveComponent/src/Exception/UnsupportedHydrationException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Exception; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class UnsupportedHydrationException extends \RuntimeException +{ +} diff --git a/src/LiveComponent/src/Hydrator/DoctrineEntityPropertyHydrator.php b/src/LiveComponent/src/Hydrator/DoctrineEntityPropertyHydrator.php new file mode 100644 index 00000000000..1f0c7183a76 --- /dev/null +++ b/src/LiveComponent/src/Hydrator/DoctrineEntityPropertyHydrator.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Hydrator; + +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectManager; +use Symfony\UX\LiveComponent\Exception\UnsupportedHydrationException; +use Symfony\UX\LiveComponent\PropertyHydratorInterface; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class DoctrineEntityPropertyHydrator implements PropertyHydratorInterface +{ + /** @var ManagerRegistry[] */ + private $managerRegistries; + + /** + * @param ManagerRegistry[] $managerRegistries + */ + public function __construct(iterable $managerRegistries) + { + $this->managerRegistries = $managerRegistries; + } + + public function dehydrate($value) + { + if (!\is_object($value)) { + throw new UnsupportedHydrationException(); + } + + $id = $this + ->objectManagerFor($class = \get_class($value)) + ->getClassMetadata($class) + ->getIdentifierValues($value) + ; + + switch (\count($id)) { + case 0: + throw new \RuntimeException("Cannot dehydrate an unpersisted entity ({$class}). If you want to allow this, add a dehydrateWith= option to LiveProp."); + case 1: + return array_values($id)[0]; + } + + // composite id + return $id; + } + + public function hydrate(string $type, $value) + { + return $this->objectManagerFor($type)->find($type, $value); + } + + private function objectManagerFor(string $class): ObjectManager + { + foreach ($this->managerRegistries as $registry) { + if ($om = $registry->getManagerForClass($class)) { + return $om; + } + } + + throw new UnsupportedHydrationException(); + } +} diff --git a/src/LiveComponent/src/Hydrator/NormalizerBridgePropertyHydrator.php b/src/LiveComponent/src/Hydrator/NormalizerBridgePropertyHydrator.php new file mode 100644 index 00000000000..36efc6b5694 --- /dev/null +++ b/src/LiveComponent/src/Hydrator/NormalizerBridgePropertyHydrator.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Hydrator; + +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\UX\LiveComponent\Exception\UnsupportedHydrationException; +use Symfony\UX\LiveComponent\PropertyHydratorInterface; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class NormalizerBridgePropertyHydrator implements PropertyHydratorInterface +{ + /** @var NormalizerInterface|DenormalizerInterface */ + private $normalizer; + + public function __construct(NormalizerInterface $normalizer) + { + if (!$normalizer instanceof DenormalizerInterface) { + throw new \InvalidArgumentException('Normalizer must also be a Denormalizer.'); + } + + $this->normalizer = $normalizer; + } + + public function dehydrate($value) + { + if (!$this->normalizer->supportsNormalization($value)) { + throw new UnsupportedHydrationException(); + } + + return $this->normalizer->normalize($value); + } + + public function hydrate(string $type, $value) + { + if (!$this->normalizer->supportsDenormalization($value, $type)) { + throw new UnsupportedHydrationException(); + } + + return $this->normalizer->denormalize($value, $type); + } +} diff --git a/src/LiveComponent/src/LiveComponentBundle.php b/src/LiveComponent/src/LiveComponentBundle.php new file mode 100644 index 00000000000..e1fd8c6878f --- /dev/null +++ b/src/LiveComponent/src/LiveComponentBundle.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class LiveComponentBundle extends Bundle +{ +} diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php new file mode 100644 index 00000000000..9c084660a00 --- /dev/null +++ b/src/LiveComponent/src/LiveComponentHydrator.php @@ -0,0 +1,360 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent; + +use Doctrine\Common\Annotations\Reader; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\Attribute\PostHydrate; +use Symfony\UX\LiveComponent\Attribute\PreDehydrate; +use Symfony\UX\LiveComponent\Exception\UnsupportedHydrationException; + +/** + * @author Kevin Bond + * + * @experimental + * + * @internal + */ +final class LiveComponentHydrator +{ + private const CHECKSUM_KEY = '_checksum'; + private const EXPOSED_PROP_KEY = 'id'; + + /** @var PropertyHydratorInterface[] */ + private $propertyHydrators; + + /** @var PropertyAccessorInterface */ + private $propertyAccessor; + + /** @var Reader */ + private $annotationReader; + + /** @var string */ + private $secret; + + /** + * @param PropertyHydratorInterface[] $propertyHydrators + */ + public function __construct(iterable $propertyHydrators, PropertyAccessorInterface $propertyAccessor, Reader $annotationReader, string $secret) + { + $this->propertyHydrators = $propertyHydrators; + $this->propertyAccessor = $propertyAccessor; + $this->annotationReader = $annotationReader; + $this->secret = $secret; + } + + public function isActionAllowed(LiveComponentInterface $component, string $action): bool + { + foreach ((new \ReflectionClass($component))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($action === $method->name && $this->annotationReader->getMethodAnnotation($method, LiveAction::class)) { + return true; + } + } + + return false; + } + + public function dehydrate(LiveComponentInterface $component): array + { + foreach ($this->preDehydrateMethods($component) as $method) { + $component->{$method->name}(); + } + + $data = []; + $readonlyProperties = []; + + $frontendPropertyNames = []; + foreach ($this->reflectionProperties($component) as $property) { + $liveProp = $this->livePropFor($property); + $name = $property->getName(); + $frontendName = $this->getFrontendFieldName($liveProp, $component, $property); + + if (isset($frontendPropertyNames[$frontendName])) { + $message = sprintf('The field name "%s" cannot be used by multiple LiveProp properties in a component. Currently, both "%s" and "%s" are trying to use it in "%s".', $frontendName, $frontendPropertyNames[$frontendName], $name, \get_class($component)); + + if ($frontendName === $frontendPropertyNames[$frontendName] || $frontendName === $name) { + $message .= sprintf(' Try adding LiveProp(fieldName="somethingElse") for the "%s" property to avoid this.', $frontendName); + } + + throw new \LogicException($message); + } + $frontendPropertyNames[$frontendName] = $name; + + if ($liveProp->isReadonly()) { + $readonlyProperties[] = $frontendName; + } + + // TODO: improve error message if not readable + $value = $this->propertyAccessor->getValue($component, $name); + + $dehydratedValue = null; + if ($method = $liveProp->dehydrateMethod()) { + // TODO: Error checking + $dehydratedValue = $component->$method($value); + } else { + $dehydratedValue = $this->dehydrateProperty($value, $name, $component); + } + + if (\count($liveProp->exposed()) > 0) { + $data[$frontendName] = [ + self::EXPOSED_PROP_KEY => $dehydratedValue, + ]; + foreach ($liveProp->exposed() as $propertyPath) { + $value = $this->propertyAccessor->getValue($component, sprintf('%s.%s', $name, $propertyPath)); + $data[$frontendName][$propertyPath] = $this->dehydrateProperty($value, $propertyPath, $component); + } + } else { + $data[$frontendName] = $dehydratedValue; + } + } + + $data[self::CHECKSUM_KEY] = $this->computeChecksum($data, $readonlyProperties); + + return $data; + } + + public function hydrate(LiveComponentInterface $component, array $data): void + { + $readonlyProperties = []; + + /* + * Determine readonly properties for checksum verification. We need to do this + * before setting properties on the component. It is unlikely but there could + * be security implications to doing it after (component setter's could have + * side effects). + */ + foreach ($this->reflectionProperties($component) as $property) { + $liveProp = $this->livePropFor($property); + if ($liveProp->isReadonly()) { + $readonlyProperties[] = $this->getFrontendFieldName($liveProp, $component, $property); + } + } + + $this->verifyChecksum($data, $readonlyProperties); + + unset($data[self::CHECKSUM_KEY]); + + foreach ($this->reflectionProperties($component) as $property) { + $liveProp = $this->livePropFor($property); + $name = $property->getName(); + $frontendName = $this->getFrontendFieldName($liveProp, $component, $property); + + if (!\array_key_exists($frontendName, $data)) { + // this property was not sent + continue; + } + + $dehydratedValue = $data[$frontendName]; + // if there are exposed keys, then the main value should be hidden + // in an array under self::EXPOSED_PROP_KEY. But if the value is + // *not* an array, then use the main value. This could mean that, + // for example, in a "post.title" situation, the "post" itself was changed. + if (\count($liveProp->exposed()) > 0 && isset($dehydratedValue[self::EXPOSED_PROP_KEY])) { + $dehydratedValue = $dehydratedValue[self::EXPOSED_PROP_KEY]; + unset($data[$frontendName][self::EXPOSED_PROP_KEY]); + } + + if ($method = $liveProp->hydrateMethod()) { + // TODO: Error checking + $value = $component->$method($dehydratedValue); + } else { + $value = $this->hydrateProperty($property, $dehydratedValue); + } + + foreach ($liveProp->exposed() as $exposedProperty) { + $propertyPath = $this->transformToArrayPath("{$name}.$exposedProperty"); + + if (!$this->propertyAccessor->isReadable($data, $propertyPath)) { + continue; + } + + // easy way to read off of the array + $exposedPropertyData = $this->propertyAccessor->getValue($data, $propertyPath); + + try { + $this->propertyAccessor->setValue( + $value, + $exposedProperty, + $exposedPropertyData + ); + } catch (UnexpectedTypeException $e) { + throw new \LogicException(sprintf('Unable to set the exposed field "%s" onto the "%s" property because it has an invalid type (%s).', $exposedProperty, $name, get_debug_type($value)), 0, $e); + } + } + + // TODO: improve error message if not writable + $this->propertyAccessor->setValue($component, $name, $value); + } + + foreach ($this->postHydrateMethods($component) as $method) { + $component->{$method->name}(); + } + } + + private function computeChecksum(array $data, array $readonlyProperties): string + { + // filter to only readonly properties + $properties = array_filter($data, static fn ($key) => \in_array($key, $readonlyProperties, true), \ARRAY_FILTER_USE_KEY); + + // for read-only properties with "exposed" sub-parts, + // only use the main value + foreach ($properties as $key => $val) { + if (\in_array($key, $readonlyProperties) && \is_array($val)) { + $properties[$key] = $val[self::EXPOSED_PROP_KEY]; + } + } + + // sort so it is always consistent (frontend could have re-ordered data) + ksort($properties); + + return base64_encode(hash_hmac('sha256', http_build_query($properties), $this->secret, true)); + } + + private function verifyChecksum(array $data, array $readonlyProperties): void + { + if (!\array_key_exists(self::CHECKSUM_KEY, $data)) { + throw new UnprocessableEntityHttpException('No checksum!'); + } + + if (!hash_equals($this->computeChecksum($data, $readonlyProperties), $data[self::CHECKSUM_KEY])) { + throw new UnprocessableEntityHttpException('Invalid checksum!'); + } + } + + /** + * @param scalar|array|null $value + * + * @return mixed + */ + private function hydrateProperty(\ReflectionProperty $property, $value) + { + // TODO: make compatible with PHP 7.2 + if (!$property->getType() || !$property->getType() instanceof \ReflectionNamedType || $property->getType()->isBuiltin()) { + return $value; + } + + foreach ($this->propertyHydrators as $hydrator) { + try { + return $hydrator->hydrate($property->getType()->getName(), $value); + } catch (UnsupportedHydrationException $e) { + continue; + } + } + + return $value; + } + + /** + * @param mixed $value + * + * @return scalar|array|null + */ + private function dehydrateProperty($value, string $name, LiveComponentInterface $component) + { + if (is_scalar($value) || \is_array($value) || null === $value) { + // nothing to dehydrate... + return $value; + } + + foreach ($this->propertyHydrators as $hydrator) { + try { + $value = $hydrator->dehydrate($value); + + break; + } catch (UnsupportedHydrationException $e) { + continue; + } + } + + if (!is_scalar($value) && !\is_array($value) && null !== $value) { + throw new \LogicException(sprintf('Cannot dehydrate property "%s" of "%s". The value "%s" does not have a dehydrator.', $name, \get_class($component), get_debug_type($value))); + } + + return $value; + } + + /** + * @param \ReflectionClass|object $object + * + * @return \ReflectionProperty[] + */ + private function reflectionProperties(object $object): iterable + { + $class = $object instanceof \ReflectionClass ? $object : new \ReflectionClass($object); + + foreach ($class->getProperties() as $property) { + if (null !== $this->livePropFor($property)) { + yield $property; + } + } + + if ($parent = $class->getParentClass()) { + yield from $this->reflectionProperties($parent); + } + } + + private function livePropFor(\ReflectionProperty $property): ?LiveProp + { + return $this->annotationReader->getPropertyAnnotation($property, LiveProp::class); + } + + /** + * Transforms a path like `post.name` into `[post][name]`. + * + * This allows us to use the property accessor to find this + * inside an array. + */ + private function transformToArrayPath(string $propertyPath): string + { + $parts = explode('.', $propertyPath); + + $path = ''; + foreach ($parts as $part) { + $path .= "[{$part}]"; + } + + return $path; + } + + /** + * @return \ReflectionMethod[] + */ + private function preDehydrateMethods(LiveComponentInterface $component): iterable + { + foreach ((new \ReflectionClass($component))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($this->annotationReader->getMethodAnnotation($method, PreDehydrate::class)) { + yield $method; + } + } + } + + /** + * @return \ReflectionMethod[] + */ + private function postHydrateMethods(LiveComponentInterface $component): iterable + { + foreach ((new \ReflectionClass($component))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($this->annotationReader->getMethodAnnotation($method, PostHydrate::class)) { + yield $method; + } + } + } + + private function getFrontendFieldName(LiveProp $liveProp, LiveComponentInterface $component, \ReflectionProperty $property): string + { + return $liveProp->calculateFieldName($component, $property->getName()); + } +} diff --git a/src/LiveComponent/src/LiveComponentInterface.php b/src/LiveComponent/src/LiveComponentInterface.php new file mode 100644 index 00000000000..f78e6b8ebce --- /dev/null +++ b/src/LiveComponent/src/LiveComponentInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent; + +use Symfony\UX\TwigComponent\ComponentInterface; + +/** + * @author Kevin Bond + * + * @experimental + */ +interface LiveComponentInterface extends ComponentInterface +{ +} diff --git a/src/LiveComponent/src/PropertyHydratorInterface.php b/src/LiveComponent/src/PropertyHydratorInterface.php new file mode 100644 index 00000000000..4cc6749e4f2 --- /dev/null +++ b/src/LiveComponent/src/PropertyHydratorInterface.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent; + +use Symfony\UX\LiveComponent\Exception\UnsupportedHydrationException; + +/** + * @author Kevin Bond + * + * @experimental + */ +interface PropertyHydratorInterface +{ + /** + * @param mixed $value + * + * @return scalar|array|null + * + * @throws UnsupportedHydrationException if unable to dehydrate + */ + public function dehydrate($value); + + /** + * @param scalar|array|null $value + * + * @return mixed + * + * @throws UnsupportedHydrationException if unable to dehydrate + */ + public function hydrate(string $type, $value); +} diff --git a/src/LiveComponent/src/Resources/config/routing/live_component.xml b/src/LiveComponent/src/Resources/config/routing/live_component.xml new file mode 100644 index 00000000000..ac87d9a8c13 --- /dev/null +++ b/src/LiveComponent/src/Resources/config/routing/live_component.xml @@ -0,0 +1,10 @@ + + + + + + get + + diff --git a/src/LiveComponent/src/Twig/LiveComponentExtension.php b/src/LiveComponent/src/Twig/LiveComponentExtension.php new file mode 100644 index 00000000000..cf50c20efc8 --- /dev/null +++ b/src/LiveComponent/src/Twig/LiveComponentExtension.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Twig; + +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class LiveComponentExtension extends AbstractExtension +{ + public function getFunctions(): array + { + return [ + new TwigFunction('init_live_component', [LiveComponentRuntime::class, 'renderLiveAttributes'], ['needs_environment' => true, 'is_safe' => ['html_attr']]), + new TwigFunction('component_url', [LiveComponentRuntime::class, 'getComponentUrl']), + ]; + } +} diff --git a/src/LiveComponent/src/Twig/LiveComponentRuntime.php b/src/LiveComponent/src/Twig/LiveComponentRuntime.php new file mode 100644 index 00000000000..e0339d8d0af --- /dev/null +++ b/src/LiveComponent/src/Twig/LiveComponentRuntime.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Twig; + +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\UX\LiveComponent\LiveComponentHydrator; +use Symfony\UX\LiveComponent\LiveComponentInterface; +use Symfony\UX\TwigComponent\ComponentInterface; +use Twig\Environment; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class LiveComponentRuntime +{ + /** @var LiveComponentHydrator */ + private $hydrator; + + /** @var UrlGeneratorInterface */ + private $urlGenerator; + + /** @var CsrfTokenManagerInterface|null */ + private $csrfTokenManager; + + public function __construct(LiveComponentHydrator $hydrator, UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager = null) + { + $this->hydrator = $hydrator; + $this->urlGenerator = $urlGenerator; + $this->csrfTokenManager = $csrfTokenManager; + } + + public function renderLiveAttributes(Environment $env, ComponentInterface $component): string + { + if (!$component instanceof LiveComponentInterface) { + throw new \InvalidArgumentException(sprintf('The "%s" component (%s) is not a LiveComponent. Don\'t forget to implement LiveComponentInterface', $component::getComponentName(), \get_class($component))); + } + + $url = $this->urlGenerator->generate('live_component', ['component' => $component::getComponentName()]); + $data = $this->hydrator->dehydrate($component); + + $ret = sprintf( + 'data-controller="live" data-live-url-value="%s" data-live-data-value="%s"', + twig_escape_filter($env, $url, 'html_attr'), + twig_escape_filter($env, json_encode($data, \JSON_THROW_ON_ERROR), 'html_attr'), + ); + + if (!$this->csrfTokenManager) { + return $ret; + } + + return sprintf('%s data-live-csrf-value="%s"', + $ret, + $this->csrfTokenManager->getToken($component::getComponentName())->getValue() + ); + } + + public function getComponentUrl(LiveComponentInterface $component): string + { + $data = $this->hydrator->dehydrate($component); + $params = ['component' => $component::getComponentName()] + $data; + + return $this->urlGenerator->generate('live_component', $params); + } +} diff --git a/src/LiveComponent/src/ValidatableComponentTrait.php b/src/LiveComponent/src/ValidatableComponentTrait.php new file mode 100644 index 00000000000..a8e6bc5ffba --- /dev/null +++ b/src/LiveComponent/src/ValidatableComponentTrait.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent; + +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\Attribute\PostHydrate; + +/** + * @author Ryan Weaver + * + * @experimental + */ +trait ValidatableComponentTrait +{ + /** @var ComponentValidatorInterface|null */ + private $componentValidator; + + /** @var array */ + private $validationErrors = []; + + /** + * Tracks whether this entire component has been validated. + * + * This is used to know if validation should be automatically applied + * when rendering. + * + * @LiveProp(writable=true) + * + * @var bool + */ + public $isValidated = false; + + /** + * Tracks which specific fields have been validated. + * + * Instead of validating the entire object (isValidated), + * the component can be validated, field-by-field. + * + * @LiveProp(writable=true) + * + * @var array + */ + public $validatedFields = []; + + /** + * Validate the entire component. + * + * This stores the validation errors: accessible via the getError() method. + */ + public function validate(bool $throw = true): void + { + $this->isValidated = true; + // set fields back to empty, as now the *entire* object is validated. + $this->validatedFields = []; + + $this->validationErrors = $this->getValidator()->validate($this); + + if (\count($this->validationErrors) > 0 && $throw) { + throw new UnprocessableEntityHttpException('Component validation failed'); + } + } + + /** + * Validates a single property (or property path) only. + * + * If a property path - like post.title - is passed, this will + * validate the *entire* "post" property. It will then loop + * over all the errors and collect only those for "post.title". + */ + public function validateField(string $propertyName, bool $throw = true): void + { + if (!\in_array($propertyName, $this->validatedFields)) { + $this->validatedFields[] = $propertyName; + } + + $errors = $this->getValidator()->validateField($this, $propertyName); + $this->validationErrors[$propertyName] = $errors; + + if (\count($errors) > 0 && $throw) { + throw new UnprocessableEntityHttpException(sprintf('The "%s" field of the component failed validation.', $propertyName)); + } + } + + /** + * Return the first validation error - if any - for a specific field. + */ + public function getError(string $propertyPath): ?ConstraintViolation + { + return $this->validationErrors[$propertyPath][0] ?? null; + } + + /** + * @return ConstraintViolation[] + */ + public function getErrors(string $propertyPath): array + { + return $this->validationErrors[$propertyPath] ?? []; + } + + public function isValid(): bool + { + return 0 === \count($this->validationErrors); + } + + /** + * Completely reset validation on this component. + */ + public function clearValidation(): void + { + $this->isValidated = false; + $this->validatedFields = []; + $this->validationErrors = []; + } + + /** + * @PostHydrate() + */ + public function validateAfterHydration() + { + if ($this->isValidated) { + $this->validate(false); + + return; + } + + if (\count($this->validatedFields) > 0) { + foreach ($this->validatedFields as $validatedField) { + $this->validateField($validatedField, false); + } + } + } + + /** + * @internal + * @required + */ + public function setComponentValidator(ComponentValidatorInterface $componentValidator): void + { + $this->componentValidator = $componentValidator; + } + + private function getValidator(): ComponentValidatorInterface + { + if (!$this->componentValidator) { + throw new \InvalidArgumentException(sprintf('The ComponentValidator service was not injected into %s. Did you forget to autowire this service or configure the setComponentValidator() call?', static::class)); + } + + return $this->componentValidator; + } +} diff --git a/src/LiveComponent/tests/Fixture/Component/Component1.php b/src/LiveComponent/tests/Fixture/Component/Component1.php new file mode 100644 index 00000000000..0b7eaac84eb --- /dev/null +++ b/src/LiveComponent/tests/Fixture/Component/Component1.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; + +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\LiveComponentInterface; +use Symfony\UX\LiveComponent\Tests\Fixture\Entity\Entity1; + +/** + * @author Kevin Bond + */ +final class Component1 implements LiveComponentInterface +{ + /** + * @LiveProp + */ + public ?Entity1 $prop1; + + /** + * @LiveProp + */ + public \DateTimeInterface $prop2; + + /** + * @LiveProp(writable=true) + */ + public $prop3; + + public $prop4; + + public static function getComponentName(): string + { + return 'component1'; + } + + /** + * @LiveAction + */ + public function method1() + { + } + + public function method2() + { + } +} diff --git a/src/LiveComponent/tests/Fixture/Component/Component2.php b/src/LiveComponent/tests/Fixture/Component/Component2.php new file mode 100644 index 00000000000..eb4134c7ffa --- /dev/null +++ b/src/LiveComponent/tests/Fixture/Component/Component2.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; + +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\UX\LiveComponent\Attribute\BeforeReRender; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\Attribute\PostHydrate; +use Symfony\UX\LiveComponent\Attribute\PreDehydrate; +use Symfony\UX\LiveComponent\LiveComponentInterface; + +/** + * @author Kevin Bond + */ +final class Component2 implements LiveComponentInterface +{ + /** + * @LiveProp + */ + public int $count = 1; + + public bool $preDehydrateCalled = false; + + public bool $postHydrateCalled = false; + + public bool $beforeReRenderCalled = false; + + /** + * @LiveAction + */ + public function increase(): void + { + ++$this->count; + } + + /** + * @LiveAction + */ + public function redirect(): RedirectResponse + { + return new RedirectResponse('/'); + } + + public static function getComponentName(): string + { + return 'component2'; + } + + /** + * @PreDehydrate() + */ + public function preDehydrateMethod(): void + { + $this->preDehydrateCalled = true; + } + + /** + * @PostHydrate() + */ + public function postHydrateMethod(): void + { + $this->postHydrateCalled = true; + } + + /** + * @BeforeReRender() + */ + public function beforeReRenderMethod(): void + { + $this->beforeReRenderCalled = true; + } +} diff --git a/src/LiveComponent/tests/Fixture/Component/Component3.php b/src/LiveComponent/tests/Fixture/Component/Component3.php new file mode 100644 index 00000000000..e818fa144c4 --- /dev/null +++ b/src/LiveComponent/tests/Fixture/Component/Component3.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; + +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\LiveComponentInterface; + +/** + * @author Kevin Bond + */ +final class Component3 implements LiveComponentInterface +{ + /** + * @LiveProp(fieldName="myProp1") + */ + public $prop1; + + /** + * @LiveProp(fieldName="getProp2Name()") + */ + public $prop2; + + public static function getComponentName(): string + { + return 'component_3'; + } + + public function getProp2Name(): string + { + return 'myProp2'; + } +} diff --git a/src/LiveComponent/tests/Fixture/Entity/Entity1.php b/src/LiveComponent/tests/Fixture/Entity/Entity1.php new file mode 100644 index 00000000000..48f63093302 --- /dev/null +++ b/src/LiveComponent/tests/Fixture/Entity/Entity1.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixture\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity + */ +class Entity1 +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + public $id; +} diff --git a/src/LiveComponent/tests/Fixture/Kernel.php b/src/LiveComponent/tests/Fixture/Kernel.php new file mode 100644 index 00000000000..365a1e6cf10 --- /dev/null +++ b/src/LiveComponent/tests/Fixture/Kernel.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixture; + +use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Psr\Log\NullLogger; +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Kernel as BaseKernel; +use Symfony\Component\Routing\RouteCollectionBuilder; +use Symfony\UX\LiveComponent\LiveComponentBundle; +use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1; +use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component2; +use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component3; +use Symfony\UX\TwigComponent\TwigComponentBundle; +use Twig\Environment; + +/** + * @author Kevin Bond + */ +final class Kernel extends BaseKernel +{ + use MicroKernelTrait; + + public function index(): Response + { + return new Response('index'); + } + + public function renderTemplate(string $template, Environment $twig = null): Response + { + $twig ??= $this->container->get('twig'); + + return new Response($twig->render("{$template}.html.twig")); + } + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new TwigBundle(); + yield new DoctrineBundle(); + yield new TwigComponentBundle(); + yield new LiveComponentBundle(); + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + // disable logging errors to the console + $c->register('logger', NullLogger::class); + + $c->register(Component1::class)->setAutoconfigured(true)->setAutowired(true); + $c->register(Component2::class)->setAutoconfigured(true)->setAutowired(true); + $c->register(Component3::class)->setAutoconfigured(true)->setAutowired(true); + + $sessionConfig = self::VERSION_ID < 50300 ? ['storage_id' => 'session.storage.mock_file'] : ['storage_factory_id' => 'session.storage.factory.mock_file']; + + $c->loadFromExtension('framework', [ + 'secret' => 'S3CRET', + 'test' => true, + 'router' => ['utf8' => true], + 'secrets' => false, + 'session' => $sessionConfig, + ]); + + $c->loadFromExtension('twig', [ + 'default_path' => '%kernel.project_dir%/tests/Fixture/templates', + ]); + + $c->loadFromExtension('doctrine', [ + 'dbal' => ['url' => '%env(resolve:DATABASE_URL)%'], + 'orm' => [ + 'auto_generate_proxy_classes' => true, + 'auto_mapping' => true, + 'mappings' => [ + 'Test' => [ + 'is_bundle' => false, + 'type' => 'annotation', + 'dir' => '%kernel.project_dir%/tests/Fixture/Entity', + 'prefix' => 'Symfony\UX\LiveComponent\Tests\Fixture\Entity', + 'alias' => 'Test', + ], + ], + ], + ]); + } + + protected function configureRoutes(RouteCollectionBuilder $routes): void + { + $routes->import('@LiveComponentBundle/Resources/config/routing/live_component.xml'); + + $routes->add('/render-template/{template}', 'kernel::renderTemplate'); + $routes->add('/', 'kernel::index'); + } +} diff --git a/src/LiveComponent/tests/Fixture/templates/components/component1.html.twig b/src/LiveComponent/tests/Fixture/templates/components/component1.html.twig new file mode 100644 index 00000000000..ad28ad5cbbd --- /dev/null +++ b/src/LiveComponent/tests/Fixture/templates/components/component1.html.twig @@ -0,0 +1,8 @@ +
+ Prop1: {{ this.prop1.id }} + Prop2: {{ this.prop2|date('Y-m-d g:i') }} + Prop3: {{ this.prop3 }} + Prop4: {{ this.prop4|default('(none)') }} +
diff --git a/src/LiveComponent/tests/Fixture/templates/components/component2.html.twig b/src/LiveComponent/tests/Fixture/templates/components/component2.html.twig new file mode 100644 index 00000000000..998865d05cc --- /dev/null +++ b/src/LiveComponent/tests/Fixture/templates/components/component2.html.twig @@ -0,0 +1,6 @@ +
+ Count: {{ this.count }} + BeforeReRenderCalled: {{ this.beforeReRenderCalled ? 'Yes' : 'No' }} +
diff --git a/src/LiveComponent/tests/Fixture/templates/template1.html.twig b/src/LiveComponent/tests/Fixture/templates/template1.html.twig new file mode 100644 index 00000000000..362c2e2b0c2 --- /dev/null +++ b/src/LiveComponent/tests/Fixture/templates/template1.html.twig @@ -0,0 +1 @@ +{{ component('component2') }} diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php new file mode 100644 index 00000000000..617af0609ad --- /dev/null +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -0,0 +1,220 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\LiveComponent\LiveComponentHydrator; +use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1; +use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component2; +use Symfony\UX\LiveComponent\Tests\Fixture\Entity\Entity1; +use Symfony\UX\TwigComponent\ComponentFactory; +use Zenstruck\Browser\Response\HtmlResponse; +use Zenstruck\Browser\Test\HasBrowser; +use function Zenstruck\Foundry\create; +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; + +/** + * @author Kevin Bond + */ +final class LiveComponentSubscriberTest extends KernelTestCase +{ + use Factories; + use HasBrowser; + use ResetDatabase; + + public function testCanRenderComponentAsHtmlOrJson(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var Component1 $component */ + $component = $factory->create(Component1::getComponentName(), [ + 'prop1' => $entity = create(Entity1::class)->object(), + 'prop2' => $date = new \DateTime('2021-03-05 9:23'), + 'prop3' => 'value3', + 'prop4' => 'value4', + ]); + + $dehydrated = $hydrator->dehydrate($component); + + $this->browser() + ->throwExceptions() + ->get('/_components/component1?'.http_build_query($dehydrated)) + ->assertSuccessful() + ->assertHeaderContains('Content-Type', 'html') + ->assertContains('Prop1: '.$entity->id) + ->assertContains('Prop2: 2021-03-05 9:23') + ->assertContains('Prop3: value3') + ->assertContains('Prop4: (none)') + + ->get('/_components/component1?'.http_build_query($dehydrated), ['headers' => ['Accept' => 'application/vnd.live-component+json']]) + ->assertSuccessful() + ->assertHeaderEquals('Content-Type', 'application/vnd.live-component+json') + ->assertJsonMatches('keys(@)', ['html', 'data']) + ->assertJsonMatches("contains(html, 'Prop1: {$entity->id}')", true) + ->assertJsonMatches("contains(html, 'Prop2: 2021-03-05 9:23')", true) + ->assertJsonMatches("contains(html, 'Prop3: value3')", true) + ->assertJsonMatches("contains(html, 'Prop4: (none)')", true) + ->assertJsonMatches('keys(data)', ['prop1', 'prop2', 'prop3', '_checksum']) + ->assertJsonMatches('data.prop1', $entity->id) + ->assertJsonMatches('data.prop2', $date->format('c')) + ->assertJsonMatches('data.prop3', 'value3') + ; + } + + public function testCanExecuteComponentAction(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var Component2 $component */ + $component = $factory->create(Component2::getComponentName()); + + $dehydrated = $hydrator->dehydrate($component); + $token = null; + + $this->browser() + ->throwExceptions() + ->get('/_components/component2?'.http_build_query($dehydrated)) + ->assertSuccessful() + ->assertHeaderContains('Content-Type', 'html') + ->assertContains('Count: 1') + ->use(function (HtmlResponse $response) use (&$token) { + // get a valid token to use for actions + $token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value'); + }) + ->post('/_components/component2/increase?'.http_build_query($dehydrated), [ + 'headers' => ['X-CSRF-TOKEN' => $token], + ]) + ->assertSuccessful() + ->assertHeaderContains('Content-Type', 'html') + ->assertContains('Count: 2') + + ->get('/_components/component2?'.http_build_query($dehydrated), ['headers' => ['Accept' => 'application/vnd.live-component+json']]) + ->assertSuccessful() + ->assertJsonMatches('data.count', 1) + ->assertJsonMatches("contains(html, 'Count: 1')", true) + ->post('/_components/component2/increase?'.http_build_query($dehydrated), [ + 'headers' => [ + 'Accept' => 'application/vnd.live-component+json', + 'X-CSRF-TOKEN' => $token, + ], + ]) + ->assertSuccessful() + ->assertJsonMatches('data.count', 2) + ->assertJsonMatches("contains(html, 'Count: 2')", true) + ; + } + + public function testCannotExecuteComponentActionForGetRequest(): void + { + $this->browser() + ->get('/_components/component2/increase') + ->assertStatus(405) + ; + } + + public function testMissingCsrfTokenForComponentActionFails(): void + { + $this->browser() + ->post('/_components/component2/increase') + ->assertStatus(400) + ; + } + + public function testInvalidCsrfTokenForComponentActionFails(): void + { + $this->browser() + ->post('/_components/component2/increase', [ + 'headers' => ['X-CSRF-TOKEN' => 'invalid'], + ]) + ->assertStatus(400) + ; + } + + public function testBeforeReRenderHookOnlyExecutedDuringAjax(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var Component2 $component */ + $component = $factory->create(Component2::getComponentName()); + + $dehydrated = $hydrator->dehydrate($component); + + $this->browser() + ->visit('/render-template/template1') + ->assertSuccessful() + ->assertSee('BeforeReRenderCalled: No') + ->get('/_components/component2?'.http_build_query($dehydrated)) + ->assertSuccessful() + ->assertSee('BeforeReRenderCalled: Yes') + ; + } + + public function testCanRedirectFromComponentAction(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var Component2 $component */ + $component = $factory->create(Component2::getComponentName()); + + $dehydrated = $hydrator->dehydrate($component); + $token = null; + + $this->browser() + ->throwExceptions() + ->get('/_components/component2?'.http_build_query($dehydrated)) + ->assertSuccessful() + ->use(function (HtmlResponse $response) use (&$token) { + // get a valid token to use for actions + $token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value'); + }) + ->interceptRedirects() + ->post('/_components/component2/redirect?'.http_build_query($dehydrated), [ + 'headers' => ['X-CSRF-TOKEN' => $token], + ]) + ->assertRedirectedTo('/') + + ->post('/_components/component2/redirect?'.http_build_query($dehydrated), [ + 'headers' => [ + 'Accept' => 'application/json', + 'X-CSRF-TOKEN' => $token, + ], + ]) + ->assertSuccessful() + ->assertJsonMatches('redirect_url', '/') + ; + } +} diff --git a/src/LiveComponent/tests/Functional/Twig/LiveComponentExtensionTest.php b/src/LiveComponent/tests/Functional/Twig/LiveComponentExtensionTest.php new file mode 100644 index 00000000000..fff7c577c42 --- /dev/null +++ b/src/LiveComponent/tests/Functional/Twig/LiveComponentExtensionTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Functional\Twig; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Browser\Test\HasBrowser; + +/** + * @author Kevin Bond + */ +final class LiveComponentExtensionTest extends KernelTestCase +{ + use HasBrowser; + + public function testInitLiveComponent(): void + { + $response = $this->browser() + ->visit('/render-template/template1') + ->assertSuccessful() + ->response() + ->assertHtml() + ; + + $div = $response->crawler()->filter('div'); + $data = json_decode($div->attr('data-live-data-value'), true); + + $this->assertSame('live', $div->attr('data-controller')); + $this->assertSame('/_components/component2', $div->attr('data-live-url-value')); + $this->assertNotNull($div->attr('data-live-csrf-value')); + $this->assertCount(2, $data); + $this->assertSame(1, $data['count']); + $this->assertArrayHasKey('_checksum', $data); + } +} diff --git a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php new file mode 100644 index 00000000000..5ca337d8e23 --- /dev/null +++ b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php @@ -0,0 +1,272 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Integration; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\LiveComponent\LiveComponentHydrator; +use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1; +use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component2; +use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component3; +use Symfony\UX\LiveComponent\Tests\Fixture\Entity\Entity1; +use Symfony\UX\TwigComponent\ComponentFactory; +use function Zenstruck\Foundry\create; +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; + +/** + * @author Kevin Bond + */ +final class LiveComponentHydratorTest extends KernelTestCase +{ + use Factories; + use ResetDatabase; + + public function testCanDehydrateAndHydrateLiveComponent(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var Component1 $component */ + $component = $factory->create(Component1::getComponentName(), [ + 'prop1' => $prop1 = create(Entity1::class)->object(), + 'prop2' => $prop2 = new \DateTime('2021-03-05 9:23'), + 'prop3' => $prop3 = 'value3', + 'prop4' => $prop4 = 'value4', + ]); + + $this->assertSame($prop1, $component->prop1); + $this->assertSame($prop2, $component->prop2); + $this->assertSame($prop3, $component->prop3); + $this->assertSame($prop4, $component->prop4); + + $dehydrated = $hydrator->dehydrate($component); + + $this->assertSame($prop1->id, $dehydrated['prop1']); + $this->assertSame($prop2->format('c'), $dehydrated['prop2']); + $this->assertSame($prop3, $dehydrated['prop3']); + $this->assertArrayHasKey('_checksum', $dehydrated); + $this->assertArrayNotHasKey('prop4', $dehydrated); + + $component = $factory->get(Component1::getComponentName()); + + $hydrator->hydrate($component, $dehydrated); + + $this->assertSame($prop1->id, $component->prop1->id); + $this->assertSame($prop2->format('c'), $component->prop2->format('c')); + $this->assertSame($prop3, $component->prop3); + $this->assertNull($component->prop4); + } + + public function testCanModifyWritableProps(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var Component1 $component */ + $component = $factory->create(Component1::getComponentName(), [ + 'prop1' => create(Entity1::class)->object(), + 'prop2' => new \DateTime('2021-03-05 9:23'), + 'prop3' => 'value3', + ]); + + $dehydrated = $hydrator->dehydrate($component); + $dehydrated['prop3'] = 'new value'; + + $component = $factory->get(Component1::getComponentName()); + + $hydrator->hydrate($component, $dehydrated); + + $this->assertSame('new value', $component->prop3); + } + + public function testCannotModifyReadonlyProps(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var Component1 $component */ + $component = $factory->create(Component1::getComponentName(), [ + 'prop1' => create(Entity1::class)->object(), + 'prop2' => new \DateTime('2021-03-05 9:23'), + 'prop3' => 'value3', + ]); + + $dehydrated = $hydrator->dehydrate($component); + $dehydrated['prop2'] = (new \DateTime())->format('c'); + + $component = $factory->get(Component1::getComponentName()); + + $this->expectException(\RuntimeException::class); + $hydrator->hydrate($component, $dehydrated); + } + + public function testHydrationFailsIfChecksumMissing(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + $this->expectException(\RuntimeException::class); + $hydrator->hydrate($factory->get(Component1::getComponentName()), []); + } + + public function testHydrationFailsOnChecksumMismatch(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + $this->expectException(\RuntimeException::class); + $hydrator->hydrate($factory->get(Component1::getComponentName()), ['_checksum' => 'invalid']); + } + + public function testCanCheckIfActionIsAllowed(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + $component = $factory->get(Component1::getComponentName()); + + $this->assertTrue($hydrator->isActionAllowed($component, 'method1')); + $this->assertFalse($hydrator->isActionAllowed($component, 'method2')); + } + + public function testPreDehydrateAndPostHydrateHooksCalled(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var Component2 $component */ + $component = $factory->create(Component2::getComponentName()); + + $this->assertFalse($component->preDehydrateCalled); + $this->assertFalse($component->postHydrateCalled); + + $data = $hydrator->dehydrate($component); + + $this->assertTrue($component->preDehydrateCalled); + $this->assertFalse($component->postHydrateCalled); + + /** @var Component2 $component */ + $component = $factory->get(Component2::getComponentName()); + + $this->assertFalse($component->preDehydrateCalled); + $this->assertFalse($component->postHydrateCalled); + + $hydrator->hydrate($component, $data); + + $this->assertFalse($component->preDehydrateCalled); + $this->assertTrue($component->postHydrateCalled); + } + + public function testDeletingEntityBetweenDehydrationAndHydrationSetsItToNull(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + $entity = create(Entity1::class); + + /** @var Component1 $component */ + $component = $factory->create(Component1::getComponentName(), [ + 'prop1' => $entity->object(), + 'prop2' => new \DateTime('2021-03-05 9:23'), + ]); + + $this->assertSame($entity->id, $component->prop1->id); + + $data = $hydrator->dehydrate($component); + + $this->assertSame($entity->id, $data['prop1']); + + $entity->remove(); + + /** @var Component1 $component */ + $component = $factory->get(Component1::getComponentName()); + + $hydrator->hydrate($component, $data); + + $this->assertNull($component->prop1); + + $data = $hydrator->dehydrate($component); + + $this->assertNull($data['prop1']); + } + + public function testCorrectlyUsesCustomFrontendNameInDehydrateAndHydrate(): void + { + self::bootKernel(); + + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::$container->get(LiveComponentHydrator::class); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var Component3 $component */ + $component = $factory->create('component_3', ['prop1' => 'value1', 'prop2' => 'value2']); + + $dehydrated = $hydrator->dehydrate($component); + + $this->assertArrayNotHasKey('prop1', $dehydrated); + $this->assertArrayNotHasKey('prop2', $dehydrated); + $this->assertArrayHasKey('myProp1', $dehydrated); + $this->assertArrayHasKey('myProp2', $dehydrated); + $this->assertSame('value1', $dehydrated['myProp1']); + $this->assertSame('value2', $dehydrated['myProp2']); + + /** @var Component3 $component */ + $component = $factory->get('component_3'); + + $hydrator->hydrate($component, $dehydrated); + + $this->assertSame('value1', $component->prop1); + $this->assertSame('value2', $component->prop2); + } +} diff --git a/src/LiveComponent/tests/Unit/Attribute/LivePropTest.php b/src/LiveComponent/tests/Unit/Attribute/LivePropTest.php new file mode 100644 index 00000000000..1dc7f871fa6 --- /dev/null +++ b/src/LiveComponent/tests/Unit/Attribute/LivePropTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Unit\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\LiveComponentInterface; + +/** + * @author Kevin Bond + */ +final class LivePropTest extends TestCase +{ + public function testHydrateWithMethod(): void + { + $this->assertSame('someMethod', (new LiveProp(['hydrateWith' => 'someMethod']))->hydrateMethod()); + $this->assertSame('someMethod', (new LiveProp(['hydrateWith' => 'someMethod()']))->hydrateMethod()); + } + + public function testDehydrateWithMethod(): void + { + $this->assertSame('someMethod', (new LiveProp(['dehydrateWith' => 'someMethod']))->dehydrateMethod()); + $this->assertSame('someMethod', (new LiveProp(['dehydrateWith' => 'someMethod()']))->dehydrateMethod()); + } + + public function testCanCallCalculateFieldNameAsString(): void + { + $component = new class() implements LiveComponentInterface { + public static function getComponentName(): string + { + return 'name'; + } + }; + + $this->assertSame('field', (new LiveProp(['fieldName' => 'field']))->calculateFieldName($component, 'fallback')); + } + + public function testCanCallCalculateFieldNameAsMethod(): void + { + $component = new class() implements LiveComponentInterface { + public static function getComponentName(): string + { + return 'name'; + } + + public function fieldName(): string + { + return 'foo'; + } + }; + + $this->assertSame('foo', (new LiveProp(['fieldName' => 'fieldName()']))->calculateFieldName($component, 'fallback')); + } + + public function testCanCallCalculateFieldNameWhenNotSet(): void + { + $component = new class() implements LiveComponentInterface { + public static function getComponentName(): string + { + return 'name'; + } + }; + + $this->assertSame('fallback', (new LiveProp([]))->calculateFieldName($component, 'fallback')); + } +} diff --git a/src/LiveComponent/tests/bootstrap.php b/src/LiveComponent/tests/bootstrap.php new file mode 100644 index 00000000000..aa69588ae53 --- /dev/null +++ b/src/LiveComponent/tests/bootstrap.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Zenstruck\Foundry\Test\TestState; + +require dirname(__DIR__).'/vendor/autoload.php'; + +TestState::disableDefaultProxyAutoRefresh(); diff --git a/src/TwigComponent/.gitattributes b/src/TwigComponent/.gitattributes new file mode 100644 index 00000000000..91aea0167b2 --- /dev/null +++ b/src/TwigComponent/.gitattributes @@ -0,0 +1,2 @@ +/.github export-ignore +/tests export-ignore diff --git a/src/TwigComponent/.github/workflows/ci.yml b/src/TwigComponent/.github/workflows/ci.yml new file mode 100644 index 00000000000..c75f386aabb --- /dev/null +++ b/src/TwigComponent/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + pull_request: + +jobs: + tests: + name: PHP ${{ matrix.php }}, SF ${{ matrix.symfony }} - ${{ matrix.stability }} + runs-on: ubuntu-latest + strategy: + matrix: + php: [7.2, 7.4, 8.0] + stability: [hightest] + symfony: [4.4.*, 5.2.*, 5.3.*] + include: + - php: 7.2 + stability: lowest + symfony: '*' + - php: 8.0 + stability: highest + symfony: '5.4.*@dev' + steps: + - name: Checkout code + uses: actions/checkout@v2.3.3 + + - name: Setup PHP + uses: shivammathur/setup-php@2.7.0 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Install Symfony Flex + run: composer global require --no-progress --no-scripts --no-plugins symfony/flex dev-main + + - name: Set minimum-stability to dev + run: composer config minimum-stability dev + if: ${{ contains(matrix.symfony, '@dev') }} + + - name: Install dependencies + uses: ramsey/composer-install@v1 + with: + dependency-versions: ${{ matrix.stability }} + composer-options: --prefer-dist + env: + SYMFONY_REQUIRE: ${{ matrix.symfony }} + + - name: Test + run: vendor/bin/simple-phpunit -v diff --git a/src/TwigComponent/.gitignore b/src/TwigComponent/.gitignore new file mode 100644 index 00000000000..854217846fe --- /dev/null +++ b/src/TwigComponent/.gitignore @@ -0,0 +1,5 @@ +/composer.lock +/phpunit.xml +/vendor/ +/var/ +/.phpunit.result.cache diff --git a/src/TwigComponent/LICENSE b/src/TwigComponent/LICENSE new file mode 100644 index 00000000000..45c069b323b --- /dev/null +++ b/src/TwigComponent/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/TwigComponent/README.md b/src/TwigComponent/README.md new file mode 100644 index 00000000000..dba56817fa5 --- /dev/null +++ b/src/TwigComponent/README.md @@ -0,0 +1,317 @@ +# Twig Components + +**EXPERIMENTAL** This component is currently experimental and is +likely to change, or even change drastically. + +Twig components give you the power to bind an object to a template, making +it easier to render and re-use small template "units" - like an "alert", +markup for a modal, or a category sidebar: + +Every component consists of (1) a class: + +```php +// src/Components/AlertComponent.php +namespace App\Components; + +use Symfony\UX\TwigComponent\ComponentInterface; + +class AlertComponent implements ComponentInterface +{ + public string $type = 'success'; + public string $message; + + public static function getComponentName(): string + { + return 'alert'; + } +} +``` + +And (2) a corresponding template: + +```twig +{# templates/components/alert.html.twig #} +
+ {{ this.message }} +
+``` + +Done! Now render it wherever you want: + +```twig +{{ component('alert', { message: 'Hello Twig Components!' }) }} +``` + +Enjoy your new component! + +![Example of the AlertComponent](./alert-example.png) + +This brings the familiar "component" system from client-side frameworks +into Symfony. Combine this with [Live Components](../LiveComponent), +to create an interactive frontend with automatic, Ajax-powered rendering. + +## Installation + +Let's get this thing installed! Run: + +``` +composer require symfony/ux-twig-component +``` + +That's it! We're ready to go! + +## Creating a Basic Component + +Let's create a reusable "alert" element that we can use to show +success or error messages across our site. Step 1 is always to create +a component that implements `ComponentInterface`. Let's start as simple +as possible: + +```php +// src/Components/AlertComponent.php +namespace App\Components; + +use Symfony\UX\TwigComponent\ComponentInterface; + +class AlertComponent implements ComponentInterface +{ + public static function getComponentName(): string + { + return 'alert'; + } +} +``` + +Step 2 is to create a template for this component. Templates live +in `templates/components/{Component Name}.html.twig`, where +`{Component Name}` is whatever you return from the `getComponentName()` +method: + +```twig +{# templates/components/alert.html.twig #} +
+ Success! You've created a Twig component! +
+``` + +This isn't very interesting yet... since the message is hardcoded +into the template. But it's enough! Celebrate by rendering your +component from any other Twig template: + +```twig +{{ component('alert') }} +``` + +Done! You've just rendered your first Twig Component! Take a moment +to fist pump - then come back! + +## Passing Data into your Component + +Good start: but this isn't very interesting yet! To make our +`alert` component reusable, we need to make the message and +type (e.g. `success`, `danger`, etc) configurable. To do +that, create a public property for each: + +```diff +// src/Components/AlertComponent.php +// ... + +class AlertComponent implements ComponentInterface +{ ++ public string $message; + ++ public string $type = 'success'; + + // ... +} +``` + +In the template, the `AlertComponent` instance is available via +the `this` variable. Use it to render the two new properties: + +```twig +
+ {{ this.message }} +
+``` + +How can we populate the `message` and `type` properties? By passing them +as a 2nd argument to the `component()` function when rendering: + +```twig +{{ component('alert', { message: 'Successfully created!' }) }} + +{{ component('alert', { + type: 'danger', + message: 'Danger Will Robinson!' +}) }} +``` + +Behind the scenes, a new `AlertComponent` will be instantiated and +the `message` key (and `type` if passed) will be set onto the `$message` +property of the object. Then, the component is rendered! If a +property has a setter method (e.g. `setMessage()`), that will +be called instead of setting the property directly. + +### The mount() Method + +If, for some reason, you don't want an option to the `component()` +function to be set directly onto a property, you can, instead, create +a `mount()` method in your component: + +```php +// src/Components/AlertComponent.php +// ... + +class AlertComponent implements ComponentInterface +{ + public string $message; + public string $type = 'success'; + + public function mount(bool $isSuccess = true) + { + $this->type = $isSuccess ? 'success' : 'danger'; + } + + // ... +} +``` + +The `mount()` method is called just one time immediately after your +component is instantiated. Because the method has an `$isSuccess` +argument, we can pass an `isSuccess` option when rendering the +component: + +```twig +{{ component('alert', { + isSuccess: false, + message: 'Danger Will Robinson!' +}) }} +``` + +If an option name matches an argument name in `mount()`, the +option is passed as that argument and the component system +will _not_ try to set it directly on a property. + +## Fetching Services + +Let's create a more complex example: a "featured products" component. +You _could_ choose to pass an array of Product objects into the +`component()` function and set those on a `$products` property. But +instead, let's allow the component to do the work of executing the query. + +How? Components are _services_, which means autowiring +works like normal. This example assumes you have a `Product` +Doctrine entity and `ProductRepository`: + +```php +// src/Components/FeaturedProductsComponent.php +namespace App\Components; + +use App\Repository\ProductRepository; +use Symfony\UX\TwigComponent\ComponentInterface; + +class FeaturedProductsComponent implements ComponentInterface +{ + private ProductRepository $productRepository; + + public function __construct(ProductRepository $productRepository) + { + $this->productRepository = $productRepository; + } + + public function getProducts(): array + { + // an example method that returns an array of Products + return $this->productRepository->findFeatured(); + } + + public static function getComponentName() : string + { + return 'featured_products'; + } +} +``` + +In the template, the `getProducts()` method can be accessed via +`this.products`: + +```twig +{# templates/components/featured_products.html.twig #} + +
+

Featured Products

+ + {% for product in this.products %} + ... + {% endfor %} +
+``` + +And because this component doesn't have any public properties that +we need to populate, you can render it with: + +```twig +{{ component('featured_products') }} +``` + +**NOTE** +Because components are services, normal dependency injection +can be used. However, each component service is registered with +`shared: false`. That means that you can safely render the same +component multiple times with different data because each +component will be an independent instance. + +### Computed Properties + +In the previous example, instead of querying for the featured products +immediately (e.g. in `__construct()`), we created a `getProducts()` +method and called that from the template via `this.products`. + +This was done because, as a general rule, you should make your components +as _lazy_ as possible and store only the information you need on its +properties (this also helps if you convert your component to a +[live component](../LiveComponent)) later. With this setup, the +query is only executed if and when the `getProducts()` method +is actually called. This is very similar to the idea of +"computed properties" in frameworks like [Vue](https://v3.vuejs.org/guide/computed.html). + +But there's no magic with the `getProducts()` method: if you +call `this.products` multiple times in your template, the query +would be executed multiple times. + +To make your `getProducts()` method act like a true computed property +(where its value is only evaluated the first time you call the +method), you can store its result on a private property: + +```diff +// src/Components/FeaturedProductsComponent.php +namespace App\Components; +// ... + +class FeaturedProductsComponent implements ComponentInterface +{ + private ProductRepository $productRepository; + ++ private ?array $products = null; + + // ... + + public function getProducts(): array + { ++ if ($this->products === null) { ++ $this->products = $this->productRepository->findFeatured(); ++ } + +- return $this->productRepository->findFeatured(); ++ return $this->products; + } +} +``` + +## Contributing + +Interested in contributing? Visit the main source for this repository: +https://github.com/symfony/ux/tree/main/src/TwigComponent. + +Have fun! diff --git a/src/TwigComponent/alert-example.png b/src/TwigComponent/alert-example.png new file mode 100644 index 00000000000..2e9eafd759f Binary files /dev/null and b/src/TwigComponent/alert-example.png differ diff --git a/src/TwigComponent/composer.json b/src/TwigComponent/composer.json new file mode 100644 index 00000000000..48fa0a532f8 --- /dev/null +++ b/src/TwigComponent/composer.json @@ -0,0 +1,52 @@ +{ + "name": "symfony/ux-twig-component", + "type": "symfony-bundle", + "description": "Twig components for Symfony", + "keywords": [ + "symfony-ux", + "twig", + "components" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\TwigComponent\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\TwigComponent\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=7.2.5", + "twig/twig": "^2.0|^3.0", + "symfony/property-access": "^4.4|^5.0", + "symfony/dependency-injection": "^4.4|^5.0" + }, + "require-dev": { + "symfony/framework-bundle": "^4.4|^5.0", + "symfony/twig-bundle": "^4.4|^5.0", + "symfony/phpunit-bridge": "^5.2" + }, + "conflict": { + "symfony/dependency-injection": "<4.4.18,<5.1.10,<5.2.1" + }, + "extra": { + "branch-alias": { + "dev-main": "1.4-dev" + }, + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/src/TwigComponent/phpunit.xml.dist b/src/TwigComponent/phpunit.xml.dist new file mode 100644 index 00000000000..18623b5eba6 --- /dev/null +++ b/src/TwigComponent/phpunit.xml.dist @@ -0,0 +1,32 @@ + + + + + + + + + + + + + ./tests/ + + + + + + ./src + + + + + + + diff --git a/src/TwigComponent/src/ComponentFactory.php b/src/TwigComponent/src/ComponentFactory.php new file mode 100644 index 00000000000..931db633b9c --- /dev/null +++ b/src/TwigComponent/src/ComponentFactory.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent; + +use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class ComponentFactory +{ + private $components; + private $propertyAccessor; + private $serviceIdMap; + + /** + * @param ServiceLocator|ComponentInterface[] $components + */ + public function __construct(ServiceLocator $components, PropertyAccessorInterface $propertyAccessor, array $serviceIdMap) + { + $this->components = $components; + $this->propertyAccessor = $propertyAccessor; + $this->serviceIdMap = $serviceIdMap; + } + + /** + * Creates the component and "mounts" it with the passed data. + */ + public function create(string $name, array $data = []): ComponentInterface + { + $component = $this->getComponent($name); + + $this->mount($component, $data); + + // set data that wasn't set in mount on the component directly + foreach ($data as $property => $value) { + if (!$this->propertyAccessor->isWritable($component, $property)) { + throw new \LogicException(sprintf('Unable to write "%s" to component "%s". Make sure this is a writable property or create a mount() with a $%s argument.', $property, \get_class($component), $property)); + } + + $this->propertyAccessor->setValue($component, $property, $value); + } + + return $component; + } + + /** + * Returns the "unmounted" component. + */ + public function get(string $name): ComponentInterface + { + return $this->getComponent($name); + } + + public function serviceIdFor(string $name): string + { + if (!isset($this->serviceIdMap[$name])) { + throw new \InvalidArgumentException('Component not found.'); + } + + return $this->serviceIdMap[$name]; + } + + private function mount(ComponentInterface $component, array &$data): void + { + try { + $method = (new \ReflectionClass($component))->getMethod('mount'); + } catch (\ReflectionException $e) { + // no hydrate method + return; + } + + $parameters = []; + + foreach ($method->getParameters() as $refParameter) { + $name = $refParameter->getName(); + + if (\array_key_exists($name, $data)) { + $parameters[] = $data[$name]; + + // remove the data element so it isn't used to set the property directly. + unset($data[$name]); + } elseif ($refParameter->isDefaultValueAvailable()) { + $parameters[] = $refParameter->getDefaultValue(); + } else { + throw new \LogicException(sprintf('%s::mount() has a required $%s parameter. Make sure this is passed or make give a default value.', \get_class($component), $refParameter->getName())); + } + } + + $component->mount(...$parameters); + } + + private function getComponent(string $name): ComponentInterface + { + if (!$this->components->has($name)) { + throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s', $name, implode(', ', array_keys($this->serviceIdMap)))); + } + + return $this->components->get($name); + } +} diff --git a/src/TwigComponent/src/ComponentInterface.php b/src/TwigComponent/src/ComponentInterface.php new file mode 100644 index 00000000000..bc808bcbede --- /dev/null +++ b/src/TwigComponent/src/ComponentInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent; + +/** + * @author Kevin Bond + * + * @experimental + */ +interface ComponentInterface +{ + public static function getComponentName(): string; +} diff --git a/src/TwigComponent/src/ComponentRenderer.php b/src/TwigComponent/src/ComponentRenderer.php new file mode 100644 index 00000000000..11a578dfb2e --- /dev/null +++ b/src/TwigComponent/src/ComponentRenderer.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent; + +use Twig\Environment; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class ComponentRenderer +{ + private $twig; + + public function __construct(Environment $twig) + { + $this->twig = $twig; + } + + public function render(ComponentInterface $component): string + { + // TODO: Template attribute/annotation/interface to customize + // TODO: Self-Rendering components? + $templateName = sprintf('components/%s.html.twig', $component::getComponentName()); + + return $this->twig->render($templateName, ['this' => $component]); + } +} diff --git a/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php b/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php new file mode 100644 index 00000000000..195bc075d16 --- /dev/null +++ b/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\UX\TwigComponent\ComponentFactory; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class TwigComponentPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + $serviceIdMap = []; + + foreach (array_keys($container->findTaggedServiceIds('twig.component')) as $serviceId) { + $definition = $container->getDefinition($serviceId); + + // make all component services non-shared + $definition->setShared(false); + + $name = $definition->getClass()::getComponentName(); + + // ensure component not already defined + if (\array_key_exists($name, $serviceIdMap)) { + throw new LogicException(sprintf('Component "%s" is already registered as "%s", components cannot be registered more than once.', $definition->getClass(), $serviceIdMap[$name])); + } + + // add to service id map for ComponentFactory + $serviceIdMap[$name] = $serviceId; + } + + $container->getDefinition(ComponentFactory::class)->setArgument(2, $serviceIdMap); + } +} diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php new file mode 100644 index 00000000000..b1f9464c0f0 --- /dev/null +++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\DependencyInjection; + +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentInterface; +use Symfony\UX\TwigComponent\ComponentRenderer; +use Symfony\UX\TwigComponent\Twig\ComponentExtension; +use Symfony\UX\TwigComponent\Twig\ComponentRuntime; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class TwigComponentExtension extends Extension +{ + public function load(array $configs, ContainerBuilder $container): void + { + $container->registerForAutoconfiguration(ComponentInterface::class) + ->addTag('twig.component') + ; + + $container->register(ComponentFactory::class) + ->setArguments([ + new ServiceLocatorArgument(new TaggedIteratorArgument('twig.component', null, 'getComponentName')), + new Reference('property_accessor'), + ]) + ; + + $container->register(ComponentRenderer::class) + ->setArguments([ + new Reference('twig'), + ]) + ; + + $container->register(ComponentExtension::class) + ->addTag('twig.extension') + ; + + $container->register(ComponentRuntime::class) + ->setArguments([ + new Reference(ComponentFactory::class), + new Reference(ComponentRenderer::class), + ]) + ->addTag('twig.runtime') + ; + } +} diff --git a/src/TwigComponent/src/Twig/ComponentExtension.php b/src/TwigComponent/src/Twig/ComponentExtension.php new file mode 100644 index 00000000000..00855969122 --- /dev/null +++ b/src/TwigComponent/src/Twig/ComponentExtension.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Twig; + +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class ComponentExtension extends AbstractExtension +{ + public function getFunctions(): array + { + return [ + new TwigFunction('component', [ComponentRuntime::class, 'render'], ['is_safe' => ['all']]), + ]; + } +} diff --git a/src/TwigComponent/src/Twig/ComponentRuntime.php b/src/TwigComponent/src/Twig/ComponentRuntime.php new file mode 100644 index 00000000000..ec8a534ee5f --- /dev/null +++ b/src/TwigComponent/src/Twig/ComponentRuntime.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Twig; + +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentRenderer; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class ComponentRuntime +{ + private $componentFactory; + private $componentRenderer; + + public function __construct(ComponentFactory $componentFactory, ComponentRenderer $componentRenderer) + { + $this->componentFactory = $componentFactory; + $this->componentRenderer = $componentRenderer; + } + + public function render(string $name, array $props = []): string + { + return $this->componentRenderer->render( + $this->componentFactory->create($name, $props) + ); + } +} diff --git a/src/TwigComponent/src/TwigComponentBundle.php b/src/TwigComponent/src/TwigComponentBundle.php new file mode 100644 index 00000000000..7de5ad05fab --- /dev/null +++ b/src/TwigComponent/src/TwigComponentBundle.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Bundle\Bundle; +use Symfony\UX\TwigComponent\DependencyInjection\Compiler\TwigComponentPass; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class TwigComponentBundle extends Bundle +{ + public function build(ContainerBuilder $container): void + { + $container->addCompilerPass(new TwigComponentPass()); + } +} diff --git a/src/TwigComponent/tests/Fixture/Component/ComponentA.php b/src/TwigComponent/tests/Fixture/Component/ComponentA.php new file mode 100644 index 00000000000..70720f69690 --- /dev/null +++ b/src/TwigComponent/tests/Fixture/Component/ComponentA.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Fixture\Component; + +use Symfony\UX\TwigComponent\ComponentInterface; +use Symfony\UX\TwigComponent\Tests\Fixture\Service\ServiceA; + +/** + * @author Kevin Bond + */ +final class ComponentA implements ComponentInterface +{ + public $propA; + + private $propB; + private $service; + + public function __construct(ServiceA $service) + { + $this->service = $service; + } + + public static function getComponentName(): string + { + return 'component_a'; + } + + public function getService() + { + return $this->service; + } + + public function mount($propB) + { + $this->propB = $propB; + } + + public function getPropB() + { + return $this->propB; + } +} diff --git a/src/TwigComponent/tests/Fixture/Component/ComponentB.php b/src/TwigComponent/tests/Fixture/Component/ComponentB.php new file mode 100644 index 00000000000..d35aa3afa73 --- /dev/null +++ b/src/TwigComponent/tests/Fixture/Component/ComponentB.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Fixture\Component; + +use Symfony\UX\TwigComponent\ComponentInterface; + +/** + * @author Kevin Bond + */ +final class ComponentB implements ComponentInterface +{ + public static function getComponentName(): string + { + return 'component_b'; + } +} diff --git a/src/TwigComponent/tests/Fixture/Component/ComponentC.php b/src/TwigComponent/tests/Fixture/Component/ComponentC.php new file mode 100644 index 00000000000..9cb1e28cf90 --- /dev/null +++ b/src/TwigComponent/tests/Fixture/Component/ComponentC.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Fixture\Component; + +use Symfony\UX\TwigComponent\ComponentInterface; + +/** + * @author Kevin Bond + */ +final class ComponentC implements ComponentInterface +{ + public $propA; + public $propB; + public $propC; + + public static function getComponentName(): string + { + return 'component_c'; + } + + public function mount($propA, $propB = null, $propC = 'default') + { + $this->propA = $propA; + $this->propB = $propB; + $this->propC = $propC; + } +} diff --git a/src/TwigComponent/tests/Fixture/Kernel.php b/src/TwigComponent/tests/Fixture/Kernel.php new file mode 100644 index 00000000000..e0350e97c25 --- /dev/null +++ b/src/TwigComponent/tests/Fixture/Kernel.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Fixture; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel as BaseKernel; +use Symfony\Component\Routing\RouteCollectionBuilder; +use Symfony\UX\TwigComponent\Tests\Fixture\Component\ComponentA; +use Symfony\UX\TwigComponent\Tests\Fixture\Component\ComponentB; +use Symfony\UX\TwigComponent\Tests\Fixture\Component\ComponentC; +use Symfony\UX\TwigComponent\Tests\Fixture\Service\ServiceA; +use Symfony\UX\TwigComponent\TwigComponentBundle; + +/** + * @author Kevin Bond + */ +final class Kernel extends BaseKernel +{ + use MicroKernelTrait; + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new TwigBundle(); + yield new TwigComponentBundle(); + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + $c->loadFromExtension('framework', [ + 'secret' => 'S3CRET', + 'test' => true, + 'router' => ['utf8' => true], + 'secrets' => false, + ]); + $c->loadFromExtension('twig', [ + 'default_path' => '%kernel.project_dir%/tests/Fixture/templates', + ]); + + $c->register(ServiceA::class)->setAutoconfigured(true)->setAutowired(true); + $c->register(ComponentA::class)->setAutoconfigured(true)->setAutowired(true); + $c->register('component_b', ComponentB::class)->setAutoconfigured(true)->setAutowired(true); + $c->register(ComponentC::class)->setAutoconfigured(true)->setAutowired(true); + + if ('multiple_component_b' === $this->environment) { + $c->register('different_component_b', ComponentB::class)->setAutoconfigured(true)->setAutowired(true); + } + } + + protected function configureRoutes(RouteCollectionBuilder $routes): void + { + } +} diff --git a/src/TwigComponent/tests/Fixture/Service/ServiceA.php b/src/TwigComponent/tests/Fixture/Service/ServiceA.php new file mode 100644 index 00000000000..5f4bf2cef7e --- /dev/null +++ b/src/TwigComponent/tests/Fixture/Service/ServiceA.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Fixture\Service; + +/** + * @author Kevin Bond + */ +final class ServiceA +{ + public $value = 'service a value'; +} diff --git a/src/TwigComponent/tests/Fixture/templates/components/component_a.html.twig b/src/TwigComponent/tests/Fixture/templates/components/component_a.html.twig new file mode 100644 index 00000000000..82050e8871f --- /dev/null +++ b/src/TwigComponent/tests/Fixture/templates/components/component_a.html.twig @@ -0,0 +1,3 @@ +propA: {{ this.propA }} +propB: {{ this.propB }} +service: {{ this.service.value }} diff --git a/src/TwigComponent/tests/Fixture/templates/template_a.html.twig b/src/TwigComponent/tests/Fixture/templates/template_a.html.twig new file mode 100644 index 00000000000..5ca0f7abe55 --- /dev/null +++ b/src/TwigComponent/tests/Fixture/templates/template_a.html.twig @@ -0,0 +1 @@ +{{ component('component_a', { propA: 'prop a value', propB: 'prop b value' }) }} diff --git a/src/TwigComponent/tests/Fixture/templates/template_b.html.twig b/src/TwigComponent/tests/Fixture/templates/template_b.html.twig new file mode 100644 index 00000000000..7400a62236e --- /dev/null +++ b/src/TwigComponent/tests/Fixture/templates/template_b.html.twig @@ -0,0 +1,2 @@ +{{ component('component_a', { propA: 'prop a value 1', propB: 'prop b value 1' }) }} +{{ component('component_a', { propA: 'prop a value 2', propB: 'prop b value 2' }) }} diff --git a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php new file mode 100644 index 00000000000..195356bc32d --- /dev/null +++ b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Integration; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Twig\Environment; + +/** + * @author Kevin Bond + */ +final class ComponentExtensionTest extends KernelTestCase +{ + public function testCanRenderComponent(): void + { + self::bootKernel(); + + $output = self::$container->get(Environment::class)->render('template_a.html.twig'); + + $this->assertStringContainsString('propA: prop a value', $output); + $this->assertStringContainsString('propB: prop b value', $output); + $this->assertStringContainsString('service: service a value', $output); + } + + public function testCanRenderTheSameComponentMultipleTimes(): void + { + self::bootKernel(); + + $output = self::$container->get(Environment::class)->render('template_b.html.twig'); + + $this->assertStringContainsString('propA: prop a value 1', $output); + $this->assertStringContainsString('propB: prop b value 1', $output); + $this->assertStringContainsString('propA: prop a value 2', $output); + $this->assertStringContainsString('propB: prop b value 2', $output); + $this->assertStringContainsString('service: service a value', $output); + } +} diff --git a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php new file mode 100644 index 00000000000..ccbb85b7343 --- /dev/null +++ b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Integration; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\Tests\Fixture\Component\ComponentA; +use Symfony\UX\TwigComponent\Tests\Fixture\Component\ComponentB; +use Symfony\UX\TwigComponent\Tests\Fixture\Component\ComponentC; + +/** + * @author Kevin Bond + */ +final class ComponentFactoryTest extends KernelTestCase +{ + public function testCreatedComponentsAreNotShared(): void + { + self::bootKernel(); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var ComponentA $componentA */ + $componentA = $factory->create('component_a', ['propA' => 'A', 'propB' => 'B']); + + /** @var ComponentA $componentB */ + $componentB = $factory->create('component_a', ['propA' => 'C', 'propB' => 'D']); + + $this->assertNotSame(spl_object_id($componentA), spl_object_id($componentB)); + $this->assertSame(spl_object_id($componentA->getService()), spl_object_id($componentB->getService())); + $this->assertSame('A', $componentA->propA); + $this->assertSame('B', $componentA->getPropB()); + $this->assertSame('C', $componentB->propA); + $this->assertSame('D', $componentB->getPropB()); + } + + public function testNonAutoConfiguredCreatedComponentsAreNotShared(): void + { + self::bootKernel(); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var ComponentB $componentA */ + $componentA = $factory->create('component_b'); + + /** @var ComponentB $componentB */ + $componentB = $factory->create('component_b'); + + $this->assertNotSame(spl_object_id($componentA), spl_object_id($componentB)); + } + + public function testShortNameCannotBeDifferentThanComponentName(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Component "Symfony\UX\TwigComponent\Tests\Fixture\Component\ComponentB" is already registered as "component_b", components cannot be registered more than once.'); + + self::bootKernel(['environment' => 'multiple_component_b']); + } + + public function testCanGetServiceId(): void + { + self::bootKernel(); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + $this->assertSame(ComponentA::class, $factory->serviceIdFor('component_a')); + $this->assertSame('component_b', $factory->serviceIdFor('component_b')); + } + + public function testCanGetUnmountedComponent(): void + { + self::bootKernel(); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var ComponentA $component */ + $component = $factory->get('component_a'); + + $this->assertNull($component->propA); + $this->assertNull($component->getPropB()); + } + + public function testMountCanHaveOptionalParameters(): void + { + self::bootKernel(); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + /** @var ComponentC $component */ + $component = $factory->create('component_c', [ + 'propA' => 'valueA', + 'propC' => 'valueC', + ]); + + $this->assertSame('valueA', $component->propA); + $this->assertNull($component->propB); + $this->assertSame('valueC', $component->propC); + + /** @var ComponentC $component */ + $component = $factory->create('component_c', [ + 'propA' => 'valueA', + 'propB' => 'valueB', + ]); + + $this->assertSame('valueA', $component->propA); + $this->assertSame('valueB', $component->propB); + $this->assertSame('default', $component->propC); + } + + public function testExceptionThrownIfRequiredMountParameterIsMissingFromPassedData(): void + { + self::bootKernel(); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Symfony\UX\TwigComponent\Tests\Fixture\Component\ComponentC::mount() has a required $propA parameter. Make sure this is passed or make give a default value.'); + + $factory->create('component_c'); + } + + public function testExceptionThrownIfUnableToWritePassedDataToProperty(): void + { + self::bootKernel(); + + /** @var ComponentFactory $factory */ + $factory = self::$container->get(ComponentFactory::class); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Unable to write "service" to component "Symfony\UX\TwigComponent\Tests\Fixture\Component\ComponentA". Make sure this is a writable property or create a mount() with a $service argument.'); + + $factory->create('component_a', ['propB' => 'B', 'service' => 'invalid']); + } +}