Skip to content

Commit d33c02e

Browse files
committed
feature #324 [LiveComponent] Add a generic LiveCollectionType (1ed)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [LiveComponent] Add a generic LiveCollectionType | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes <!-- please update src/**/CHANGELOG.md files --> | Tickets | - <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead --> | License | MIT Hello, this is just a quick draft, extracted from a project, to see if is it interesting enough to include here. **TODO** - [x] add to changelog - [x] add docs - [x] add tests - [ ] add a demo <!-- Replace this notice by a short README for your feature/bugfix. This will help people understand your PR and can be used as a start for the documentation. Additionally (see https://symfony.com/releases): - Always add tests and ensure they pass. - Never break backward compatibility (see https://symfony.com/bc). - Features and deprecations must be submitted against branch main. --> Commits ------- b1500fd [LiveComponent] Add a generic LiveCollectionType
2 parents b5c36bd + b1500fd commit d33c02e

File tree

12 files changed

+594
-1
lines changed

12 files changed

+594
-1
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 2.2.0
44

5+
- Add a generic `LiveCollectionType` and `LiveCollectionTrait`
56
- Allow to disable CSRF per component
67

78
## 2.1.0

src/LiveComponent/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
},
2828
"require": {
2929
"php": ">=8.0",
30+
"symfony/property-access": "^5.4|^6.0",
3031
"symfony/serializer": "^5.4|^6.0",
3132
"symfony/ux-twig-component": "^2.1"
3233
},

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,16 @@
1313

1414
use Symfony\Component\DependencyInjection\ChildDefinition;
1515
use Symfony\Component\DependencyInjection\ContainerBuilder;
16+
use Symfony\Component\DependencyInjection\Definition;
1617
use Symfony\Component\DependencyInjection\Extension\Extension;
18+
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
1719
use Symfony\Component\DependencyInjection\Reference;
1820
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
1921
use Symfony\UX\LiveComponent\ComponentValidator;
2022
use Symfony\UX\LiveComponent\ComponentValidatorInterface;
2123
use Symfony\UX\LiveComponent\EventListener\AddLiveAttributesSubscriber;
2224
use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber;
25+
use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType;
2326
use Symfony\UX\LiveComponent\LiveComponentHydrator;
2427
use Symfony\UX\LiveComponent\Twig\LiveComponentExtension as LiveComponentTwigExtension;
2528
use Symfony\UX\LiveComponent\Twig\LiveComponentRuntime;
@@ -33,8 +36,20 @@
3336
*
3437
* @internal
3538
*/
36-
final class LiveComponentExtension extends Extension
39+
final class LiveComponentExtension extends Extension implements PrependExtensionInterface
3740
{
41+
public function prepend(ContainerBuilder $container)
42+
{
43+
// Register the form theme if TwigBundle is available
44+
$bundles = $container->getParameter('kernel.bundles');
45+
46+
if (!isset($bundles['TwigBundle'])) {
47+
return;
48+
}
49+
50+
$container->prependExtensionConfig('twig', ['form_themes' => ['@LiveComponent/form_theme.html.twig']]);
51+
}
52+
3853
public function load(array $configs, ContainerBuilder $container): void
3954
{
4055
$container->registerAttributeForAutoconfiguration(
@@ -87,5 +102,11 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
87102
;
88103

89104
$container->setAlias(ComponentValidatorInterface::class, ComponentValidator::class);
105+
106+
$container
107+
->setDefinition('form.live_collection', new Definition(LiveCollectionType::class))
108+
->addTag('form.type')
109+
->setPublic(false)
110+
;
90111
}
91112
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\LiveComponent\Form\Type;
13+
14+
use Symfony\Component\Form\AbstractType;
15+
use Symfony\Component\Form\Extension\Core\Type\ButtonType;
16+
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
17+
use Symfony\Component\Form\FormBuilderInterface;
18+
use Symfony\Component\Form\FormInterface;
19+
use Symfony\Component\Form\FormView;
20+
use Symfony\Component\OptionsResolver\OptionsResolver;
21+
22+
/**
23+
* @author Gábor Egyed <[email protected]>
24+
*
25+
* @experimental
26+
*/
27+
final class LiveCollectionType extends AbstractType
28+
{
29+
public function buildForm(FormBuilderInterface $builder, array $options): void
30+
{
31+
if ($options['allow_add']) {
32+
$prototype = $builder->create('add', $options['button_add_type'], $options['button_add_options']);
33+
$builder->setAttribute('button_add_prototype', $prototype->getForm());
34+
}
35+
36+
if ($options['allow_delete']) {
37+
$prototype = $builder->create('delete', $options['button_delete_type'], $options['button_delete_options']);
38+
$builder->setAttribute('button_delete_prototype', $prototype->getForm());
39+
}
40+
}
41+
42+
public function buildView(FormView $view, FormInterface $form, array $options): void
43+
{
44+
if ($form->getConfig()->hasAttribute('button_add_prototype')) {
45+
$prototype = $form->getConfig()->getAttribute('button_add_prototype');
46+
$view->vars['button_add_prototype'] = $prototype->setParent($form)->createView($view);
47+
array_splice($view->vars['button_add_prototype']->vars['block_prefixes'], 1, 0, 'live_collection_button_add');
48+
}
49+
}
50+
51+
public function finishView(FormView $view, FormInterface $form, array $options): void
52+
{
53+
$prefixOffset = -2;
54+
// check if the entry type also defines a block prefix
55+
/** @var FormInterface $entry */
56+
foreach ($form as $entry) {
57+
if ($entry->getConfig()->getOption('block_prefix')) {
58+
--$prefixOffset;
59+
}
60+
61+
break;
62+
}
63+
64+
foreach ($view as $entryView) {
65+
array_splice($entryView->vars['block_prefixes'], $prefixOffset, 0, 'live_collection_entry');
66+
}
67+
68+
if ($form->getConfig()->hasAttribute('button_delete_prototype')) {
69+
$prototype = $form->getConfig()->getAttribute('button_delete_prototype');
70+
71+
$prototypes = [];
72+
foreach ($form as $k => $entry) {
73+
$prototypes[$k] = clone $prototype;
74+
$prototypes[$k]->setParent($entry);
75+
}
76+
77+
foreach ($view as $k => $entryView) {
78+
$entryView->vars['button_delete_prototype'] = $prototypes[$k]->createView($entryView);
79+
array_splice($entryView->vars['button_delete_prototype']->vars['block_prefixes'], 1, 0, 'live_collection_button_delete');
80+
}
81+
}
82+
}
83+
84+
public function configureOptions(OptionsResolver $resolver): void
85+
{
86+
$resolver->setDefaults([
87+
'prototype' => false,
88+
'entry_options' => [
89+
'label' => false,
90+
],
91+
'button_add_type' => ButtonType::class,
92+
'button_add_options' => [],
93+
'button_delete_type' => ButtonType::class,
94+
'button_delete_options' => [],
95+
]);
96+
}
97+
98+
public function getBlockPrefix(): string
99+
{
100+
return 'live_collection';
101+
}
102+
103+
public function getParent(): string
104+
{
105+
return CollectionType::class;
106+
}
107+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\LiveComponent;
13+
14+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
15+
use Symfony\UX\LiveComponent\Attribute\LiveAction;
16+
use Symfony\UX\LiveComponent\Attribute\LiveArg;
17+
18+
/**
19+
* @author Gábor Egyed <[email protected]>
20+
*
21+
* @experimental
22+
*/
23+
trait LiveCollectionTrait
24+
{
25+
use ComponentWithFormTrait;
26+
27+
#[LiveAction]
28+
public function addCollectionItem(PropertyAccessorInterface $propertyAccessor, #[LiveArg] string $name): void
29+
{
30+
if (str_starts_with($name, $this->formName)) {
31+
$name = substr_replace($name, '', 0, mb_strlen($this->formName));
32+
}
33+
34+
$data = $propertyAccessor->getValue($this->formValues, $name);
35+
36+
if (!\is_array($data)) {
37+
$propertyAccessor->setValue($this->formValues, $name, []);
38+
$data = [];
39+
}
40+
41+
$index = [] !== $data ? max(array_keys($data)) + 1 : 0;
42+
$propertyAccessor->setValue($this->formValues, $name."[$index]", []);
43+
}
44+
45+
#[LiveAction]
46+
public function removeCollectionItem(PropertyAccessorInterface $propertyAccessor, #[LiveArg] string $name, #[LiveArg] int $index): void
47+
{
48+
if (str_starts_with($name, $this->formName)) {
49+
$name = substr_replace($name, '', 0, mb_strlen($this->formName));
50+
}
51+
52+
$data = $propertyAccessor->getValue($this->formValues, $name);
53+
unset($data[$index]);
54+
$propertyAccessor->setValue($this->formValues, $name, $data);
55+
}
56+
}

src/LiveComponent/src/Resources/doc/index.rst

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,6 +1220,106 @@ When the user clicks ``removeComment()``, a similar process happens.
12201220
attribute above the ``Post.comments`` property. These help new
12211221
items save and deletes any items whose embedded forms are removed.
12221222

1223+
Using LiveCollectionType
1224+
~~~~~~~~~~~~~~~~~~~~~~~~
1225+
1226+
.. versionadded:: 2.2
1227+
1228+
The ``LiveCollectionType`` and the ``LiveCollectionTrait`` was added in LiveComponent 2.2.
1229+
1230+
1231+
The ``LiveCollectionType`` uses the same method described above, but in
1232+
a generic way, so it needs even less code. This form type adds an 'Add'
1233+
and a 'Delete' button for each row by default, which work out of the box
1234+
thanks to the ``LiveCollectionTrait``.
1235+
1236+
Let's take the same example as before, a "Blog Post" form with an embedded "Comment" forms
1237+
via the ``LiveCollectionType``::
1238+
1239+
namespace App\Form;
1240+
1241+
use Symfony\Component\Form\AbstractType;
1242+
use Symfony\Component\Form\FormBuilderInterface;
1243+
use Symfony\Component\OptionsResolver\OptionsResolver;
1244+
use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType;
1245+
use App\Entity\BlogPost;
1246+
1247+
class BlogPostFormType extends AbstractType
1248+
{
1249+
public function buildForm(FormBuilderInterface $builder, array $options)
1250+
{
1251+
$builder
1252+
->add('title', TextType::class)
1253+
// ...
1254+
->add('comments', LiveCollectionType::class, [
1255+
'entry_type' => CommentFormType::class,
1256+
'allow_add' => true,
1257+
'allow_delete' => true,
1258+
'by_reference' => false,
1259+
])
1260+
;
1261+
}
1262+
1263+
public function configureOptions(OptionsResolver $resolver)
1264+
{
1265+
$resolver->setDefaults(['data_class' => BlogPost::class]);
1266+
}
1267+
}
1268+
1269+
Now, create a Twig component to render the form::
1270+
1271+
namespace App\Twig;
1272+
1273+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
1274+
use Symfony\Component\Form\FormInterface;
1275+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
1276+
use Symfony\UX\LiveComponent\DefaultActionTrait;
1277+
use Symfony\UX\LiveComponent\LiveCollectionTrait;
1278+
use App\Entity\BlogPost;
1279+
use App\Form\BlogPostFormType;
1280+
1281+
#[AsLiveComponent('blog_post_collection_type')]
1282+
class BlogPostCollectionTypeComponent extends AbstractController
1283+
{
1284+
use LiveCollectionTrait;
1285+
use DefaultActionTrait;
1286+
1287+
#[LiveProp]
1288+
public BlogPost $post;
1289+
1290+
protected function instantiateForm(): FormInterface
1291+
{
1292+
return $this->createForm(BlogPostFormType::class, $this->post);
1293+
}
1294+
}
1295+
1296+
There is no need for a custom template just render the form as usual:
1297+
1298+
.. code-block:: twig
1299+
1300+
<div {{ attributes }} data-action="change->live#update">
1301+
{{ form(form) }}
1302+
</div>
1303+
1304+
The ``add`` and ``delete`` buttons rendered as separate ``ButtonType`` form
1305+
types and can be customized like a normal form type via the ``live_collection_button_add``
1306+
and ``live_collection_button_delete`` respectively:
1307+
1308+
.. code-block:: twig
1309+
1310+
{% block live_collection_button_add_widget %}
1311+
{% set attr = attr|merge({'class': attr.class|default('btn btn-ghost')}) %}
1312+
{% set translation_domin = false %}
1313+
{% set label_html = true %}
1314+
{%- set label -%}
1315+
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
1316+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
1317+
</svg>
1318+
{{ 'form.collection.button.add.label'|trans({}, 'forms') }}
1319+
{%- endset -%}
1320+
{{ block('button_widget') }}
1321+
{% endblock live_collection_button_add_widget %}
1322+
12231323
Modifying Embedded Properties with the "exposed" Option
12241324
-------------------------------------------------------
12251325

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{%- block live_collection_widget -%}
2+
{{ block('form_widget') }}
3+
{%- if button_add_prototype is defined and not button_add_prototype.rendered -%}
4+
{{ form_row(button_add_prototype, { attr: button_add_prototype.vars.attr|merge({
5+
'data-action': 'live#action',
6+
'data-action-name': 'addCollectionItem(name=' ~ form.vars.full_name ~ ')'
7+
}) }) }}
8+
{%- endif -%}
9+
{%- endblock live_collection_widget -%}
10+
11+
{%- block live_collection_entry_row -%}
12+
{{ block('form_row') }}
13+
{%- if button_delete_prototype is defined and not button_delete_prototype.rendered -%}
14+
{{ form_row(button_delete_prototype, { attr: button_delete_prototype.vars.attr|merge({
15+
'data-action': 'live#action',
16+
'data-action-name': 'removeCollectionItem(name=' ~ form.parent.vars.full_name ~ ', index=' ~ form.vars.name ~ ')'
17+
}) }) }}
18+
{%- endif -%}
19+
{%- endblock live_collection_entry_row -%}

0 commit comments

Comments
 (0)