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 #}
+
+
+
+
+ {% for product in this.products %}
+
{{ product.name }}
+ {% endfor %}
+
+
+```
+
+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 #}
+
+```
+
+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) => `
+
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!
+
+
+
+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']);
+ }
+}