Skip to content

[Twig][Live] add component attribute system/helper #220

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 31, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@

- Send live action arguments to backend

- [BC BREAK] Remove `init_live_component()` twig function, use `{{ attributes }}` instead:
```diff
- <div {{ init_live_component() }}>
+ <div {{ attributes }}>
```

## 2.0.0

- Support for `stimulus` version 2 was removed and support for `@hotwired/stimulus`
Expand Down
2 changes: 1 addition & 1 deletion src/LiveComponent/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
},
"require": {
"php": ">=8.0",
"symfony/ux-twig-component": "^2.0"
"symfony/ux-twig-component": "^2.1"
},
"require-dev": {
"doctrine/annotations": "^1.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\ComponentValidator;
use Symfony\UX\LiveComponent\ComponentValidatorInterface;
use Symfony\UX\LiveComponent\EventListener\AddLiveAttributesSubscriber;
use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber;
use Symfony\UX\LiveComponent\LiveComponentHydrator;
use Symfony\UX\LiveComponent\PropertyHydratorInterface;
Expand All @@ -46,6 +47,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
'key' => $attribute->name,
'template' => $attribute->template,
'default_action' => $attribute->defaultAction,
'live' => true,
]))
->addTag('controller.service_arguments')
;
Expand Down Expand Up @@ -78,6 +80,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {

$container->register('ux.live_component.twig.component_runtime', LiveComponentRuntime::class)
->setArguments([
new Reference('twig'),
new Reference('ux.live_component.component_hydrator'),
new Reference('ux.twig_component.component_factory'),
new Reference(UrlGeneratorInterface::class),
Expand All @@ -90,6 +93,11 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
->addTag('container.service_subscriber', ['key' => 'validator', 'id' => 'validator'])
;

$container->register('ux.live_component.add_attributes_subscriber', AddLiveAttributesSubscriber::class)
->addTag('kernel.event_subscriber')
->addTag('container.service_subscriber', ['key' => LiveComponentRuntime::class, 'id' => 'ux.live_component.twig.component_runtime'])
;

$container->setAlias(ComponentValidatorInterface::class, ComponentValidator::class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace Symfony\UX\LiveComponent\EventListener;

use Psr\Container\ContainerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\UX\LiveComponent\Twig\LiveComponentRuntime;
use Symfony\UX\TwigComponent\ComponentAttributes;
use Symfony\UX\TwigComponent\EventListener\PreRenderEvent;

/**
* @author Kevin Bond <[email protected]>
*/
final class AddLiveAttributesSubscriber implements EventSubscriberInterface, ServiceSubscriberInterface
{
public function __construct(private ContainerInterface $container)
{
}

public function onPreRender(PreRenderEvent $event): void
{
if (!$event->getMetadata()->get('live', false)) {
// not a live component, skip
return;
}

/** @var ComponentAttributes $attributes */
$attributes = $this->container->get(LiveComponentRuntime::class)
->getLiveAttributes($event->getComponent(), $event->getMetadata())
;

$variables = $event->getVariables();

if (isset($variables['attributes']) && $variables['attributes'] instanceof ComponentAttributes) {
// merge with existing attributes if available
$attributes = $attributes->defaults($variables['attributes']->all());
}

$variables['attributes'] = $attributes;

$event->setVariables($variables);
}

public static function getSubscribedEvents(): array
{
return [PreRenderEvent::class => 'onPreRender'];
}

public static function getSubscribedServices(): array
{
return [
LiveComponentRuntime::class,
];
}
}
23 changes: 14 additions & 9 deletions src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\LiveComponentHydrator;
use Symfony\UX\TwigComponent\ComponentFactory;
use Symfony\UX\TwigComponent\ComponentMetadata;
use Symfony\UX\TwigComponent\ComponentRenderer;

/**
Expand Down Expand Up @@ -73,24 +74,28 @@ public function onKernelRequest(RequestEvent $event): void
$componentName = (string) $request->get('component');

try {
$config = $this->container->get(ComponentFactory::class)->configFor($componentName);
/** @var ComponentMetadata $metadata */
$metadata = $this->container->get(ComponentFactory::class)->metadataFor($componentName);
} catch (\InvalidArgumentException $e) {
throw new NotFoundHttpException(sprintf('Component "%s" not found.', $componentName), $e);
}

$request->attributes->set('_component_template', $config['template']);
if (!$metadata->get('live', false)) {
throw new NotFoundHttpException(sprintf('"%s" (%s) is not a Live Component.', $metadata->getClass(), $componentName));
}

$request->attributes->set('_component_metadata', $metadata);

if ('get' === $action) {
$defaultAction = trim($config['default_action'] ?? '__invoke', '()');
$componentClass = $config['class'];
$defaultAction = trim($metadata->get('default_action', '__invoke'), '()');

if (!method_exists($componentClass, $defaultAction)) {
if (!method_exists($metadata->getClass(), $defaultAction)) {
// todo should this check be in a compiler pass to ensure fails at compile time?
throw new \LogicException(sprintf('Live component "%s" requires the default action method "%s".%s', $componentClass, $defaultAction, '__invoke' === $defaultAction ? ' Either add this method or use the DefaultActionTrait' : ''));
throw new \LogicException(sprintf('Live component "%s" (%s) requires the default action method "%s".%s', $metadata->getClass(), $componentName, $defaultAction, '__invoke' === $defaultAction ? ' Either add this method or use the DefaultActionTrait' : ''));
}

// set default controller for "default" action
$request->attributes->set('_controller', sprintf('%s::%s', $config['service_id'], $defaultAction));
$request->attributes->set('_controller', sprintf('%s::%s', $metadata->getServiceId(), $defaultAction));
$request->attributes->set('_component_default_action', true);

return;
Expand All @@ -106,7 +111,7 @@ public function onKernelRequest(RequestEvent $event): void
throw new BadRequestHttpException('Invalid CSRF token.');
}

$request->attributes->set('_controller', sprintf('%s::%s', $config['service_id'], $action));
$request->attributes->set('_controller', sprintf('%s::%s', $metadata->getServiceId(), $action));
}

public function onKernelController(ControllerEvent $event): void
Expand Down Expand Up @@ -232,7 +237,7 @@ private function createResponse(object $component, Request $request): Response

$html = $this->container->get(ComponentRenderer::class)->render(
$component,
$request->attributes->get('_component_template')
$request->attributes->get('_component_metadata')
);

return new Response($html);
Expand Down
77 changes: 60 additions & 17 deletions src/LiveComponent/src/Resources/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,15 @@ A real-time product search component might look like this::
The ability to reference local variables in the template (e.g. ``query``) was added in TwigComponents 2.1.
Previously, all data needed to be referenced through ``this`` (e.g. ``this.query``).

.. versionadded:: 2.1

The ability to initialize live component with the ``attributes`` twig variable was added in LiveComponents
2.1. Previously, the ``init_live_component()`` function was required (this function was removed in 2.1).

.. code-block:: twig

{# templates/components/product_search.html.twig #}
<div {{ init_live_component(this) }}>
<div {{ attributes }}>
<input
type="search"
name="query"
Expand Down Expand Up @@ -159,13 +164,13 @@ re-rendered live on the frontend), replace the component's
}

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:
entire component and use the ``{{ attributes }}`` variable to initialize
the Stimulus controller:

.. code-block:: diff

- <div>
+ <div {{ init_live_component(this) }}>
+ <div {{ attributes }}>
<strong>{{ this.randomNumber }}</strong>
</div>

Expand All @@ -176,7 +181,7 @@ and give the user a new random number:

.. code-block:: twig

<div {{ init_live_component(this) }}>
<div {{ attributes }}>
<strong>{{ this.randomNumber }}</strong>

<button
Expand Down Expand Up @@ -239,6 +244,44 @@ 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`_.

Component Attributes
--------------------

.. versionadded:: 2.1

The ``HasAttributes`` trait was added in TwigComponents 2.1.

`Component attributes`_ allows you to render your components with extra
props that are are converted to html attributes and made available in
your component's template as an ``attributes`` variable. When used on
live components, these props are persisted between renders. You can enable
this feature by having your live component use the ``HasAttributesTrait``:

.. code-block:: diff

// ...
use Symfony\UX\LiveComponent\Attribute\LiveProp;
+ use Symfony\UX\TwigComponent\HasAttributesTrait;

#[AsLiveComponent('random_number')]
class RandomNumberComponent
{
+ use HasAttributesTrait;

#[LiveProp]
public int $min = 0;

Now, when rendering your component, you can pass html attributes
as props and these will be added to ``attributes``:

.. code-block:: twig

{{ component('random_number', { min: 5, max: 500, class: 'widget', style: 'color: black;' }) }}

{# renders as: #}
<div class="widget" style="color: black;" <!-- other live attributes -->>
<!-- ... -->

data-action=“live#update”: Re-rendering on LiveProp Change
----------------------------------------------------------

Expand All @@ -251,7 +294,7 @@ Let's add two inputs to our template:
.. code-block:: twig

{# templates/components/random_number.html.twig #}
<div {{ init_live_component(this) }}>
<div {{ attributes }}>
<input
type="number"
value="{{ min }}"
Expand Down Expand Up @@ -368,7 +411,7 @@ property. The following code works identically to the previous example:

.. code-block:: diff

<div {{ init_live_component(this)>
<div {{ attributes }}>
<input
type="number"
value="{{ min }}"
Expand Down Expand Up @@ -791,7 +834,7 @@ as ``this.form`` thanks to the trait:

{# templates/components/post_form.html.twig #}
<div
{{ init_live_component(this) }}
{{ attributes }}
{#
Automatically catch all "change" events from the fields
below and re-render the component.
Expand All @@ -815,8 +858,7 @@ as ``this.form`` thanks to the trait:
</div>

Mostly, this is a pretty boring template! It includes the normal
``init_live_component(this)`` and then you render the form however you
want.
``attributes`` 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
Expand Down Expand Up @@ -1024,7 +1066,7 @@ section above) is to add:
.. code-block:: diff

<div
{{ init_live_component(this) }}
{{ attributes }}
+ data-action="change->live#update"
>

Expand Down Expand Up @@ -1056,7 +1098,7 @@ rendered the ``content`` through a Markdown filter from the

.. code-block:: twig

<div {{init_live_component(this)}}>
<div {{ attributes }}>
<input
type="text"
value="{{ post.title }}"
Expand Down Expand Up @@ -1221,7 +1263,7 @@ You can also use “polling” to continually refresh a component. On the
.. code-block:: diff

<div
{{ init_live_component(this) }}
{{ attributes }}
+ data-poll
>

Expand All @@ -1233,7 +1275,7 @@ delay for 500ms:
.. code-block:: twig

<div
{{ init_live_component(this) }}
{{ attributes }}
data-poll="delay(500)|$render"
>

Expand All @@ -1242,7 +1284,7 @@ You can also trigger a specific “action” instead of a normal re-render:
.. code-block:: twig

<div
{{ init_live_component(this) }}
{{ attributes }}

data-poll="save"
{#
Expand Down Expand Up @@ -1437,7 +1479,7 @@ In the ``EditPostComponent`` template, you render the
.. code-block:: twig

{# templates/components/edit_post.html.twig #}
<div {{ init_live_component(this) }}>
<div {{ attributes }}>
<input
type="text"
name="post[title]"
Expand All @@ -1459,7 +1501,7 @@ In the ``EditPostComponent`` template, you render the

.. code-block:: twig

<div {{ init_live_component(this) }} class="mb-3">
<div {{ attributes }} class="mb-3">
<textarea
name="{{ name }}"
data-model="value"
Expand Down Expand Up @@ -1496,3 +1538,4 @@ bound to Symfony's BC policy for the moment.
.. _`experimental`: https://symfony.com/doc/current/contributing/code/experimental.html
.. _`dependent form fields`: https://symfony.com/doc/current/form/dynamic_form_modification.html#dynamic-generation-for-submitted-forms
.. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html
.. _`Component attributes`: https://symfony.com/bundles/ux-twig-component/current/index.html#component-attributes
1 change: 0 additions & 1 deletion src/LiveComponent/src/Twig/LiveComponentExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ final class LiveComponentExtension extends AbstractExtension
public function getFunctions(): array
{
return [
new TwigFunction('init_live_component', [LiveComponentRuntime::class, 'renderLiveAttributes'], ['needs_environment' => true, 'is_safe' => ['html_attr']]),
new TwigFunction('component_url', [LiveComponentRuntime::class, 'getComponentUrl']),
];
}
Expand Down
Loading