diff --git a/src/FormCollection/.gitattributes b/src/FormCollection/.gitattributes new file mode 100644 index 00000000000..17bf7a840e9 --- /dev/null +++ b/src/FormCollection/.gitattributes @@ -0,0 +1,3 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/Resources/assets/test export-ignore diff --git a/src/FormCollection/DependencyInjection/FormCollectionExtension.php b/src/FormCollection/DependencyInjection/FormCollectionExtension.php new file mode 100644 index 00000000000..3266037fd37 --- /dev/null +++ b/src/FormCollection/DependencyInjection/FormCollectionExtension.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\FormCollection\DependencyInjection; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\UX\FormCollection\Form\UXCollectionType; + +/** + * @internal + */ +class FormCollectionExtension extends Extension +{ + public function load(array $configs, ContainerBuilder $container) + { + $container + ->setDefinition('form.ux_collection', new Definition(UXCollectionType::class)) + ->addTag('form.type') + ->setPublic(false) + ; + } +} diff --git a/src/FormCollection/Form/UXCollectionType.php b/src/FormCollection/Form/UXCollectionType.php new file mode 100644 index 00000000000..42affc88800 --- /dev/null +++ b/src/FormCollection/Form/UXCollectionType.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\FormCollection\Form; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @final + * @experimental + */ +class UXCollectionType extends AbstractType +{ + public function getParent() + { + return CollectionType::class; + } + + public function configureOptions(OptionsResolver $resolver) + { + $defaultButtonAddOptions = [ + 'label' => 'Add', + 'class' => '', + ]; + $defaultButtonDeleteOptions = [ + 'label' => 'Remove', + 'class' => '', + ]; + $resolver->setDefaults([ + 'button_add_options' => $defaultButtonAddOptions, + 'button_delete_options' => $defaultButtonDeleteOptions, + ]); + + $resolver->setAllowedTypes('button_add_options', 'array'); + $resolver->setAllowedTypes('button_delete_options', 'array'); + + $resolver->setNormalizer('button_add_options', function (Options $options, $value) use ($defaultButtonAddOptions) { + $value['label'] = $value['label'] ?? $defaultButtonAddOptions['label']; + $value['class'] = $value['class'] ?? $defaultButtonAddOptions['class']; + + return $value; + }); + $resolver->setNormalizer('button_delete_options', function (Options $options, $value) use ($defaultButtonDeleteOptions) { + $value['label'] = $value['label'] ?? $defaultButtonDeleteOptions['label']; + $value['class'] = $value['class'] ?? $defaultButtonDeleteOptions['class']; + + return $value; + }); + } + + public function finishView(FormView $view, FormInterface $form, array $options) + { + parent::finishView($view, $form, $options); + + $view->vars['button_add_options'] = $options['button_add_options']; + $view->vars['button_delete_options'] = $options['button_delete_options']; + $view->vars['prototype_name'] = $options['prototype_name']; + } + + public function getBlockPrefix() + { + return 'ux_collection'; + } +} diff --git a/src/FormCollection/FormCollectionBundle.php b/src/FormCollection/FormCollectionBundle.php new file mode 100644 index 00000000000..7eae8679a47 --- /dev/null +++ b/src/FormCollection/FormCollectionBundle.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\FormCollection; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +/** + * @final + * @experimental + */ +class FormCollectionBundle extends Bundle +{ +} diff --git a/src/FormCollection/LICENSE b/src/FormCollection/LICENSE new file mode 100644 index 00000000000..ad85e173748 --- /dev/null +++ b/src/FormCollection/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-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/FormCollection/README.md b/src/FormCollection/README.md new file mode 100644 index 00000000000..d34a380e400 --- /dev/null +++ b/src/FormCollection/README.md @@ -0,0 +1,190 @@ +# UX Form Collection + +Symfony UX Form collection is a Symfony bundle providing light UX for collection +in Symfony Forms. + +## Installation + +UX Form Collection requires PHP 7.2+ and Symfony 4.4+. + +Install this bundle using Composer and Symfony Flex: + +```sh +composer require symfony/ux-form-collection + +# Don't forget to install the JavaScript dependencies as well and compile +yarn install --force +yarn encore dev +``` + +Also make sure you have at least version 2.0 of [@symfony/stimulus-bridge](https://github.com/symfony/stimulus-bridge) +in your `package.json` file. + +## Use predefined theme + +You need to select the right theme from the one you are using: + +```yaml +# config/packages/twig.yaml +twig: + # For bootstrap for example + form_themes: ['@FormCollection/form_theme_div.html.twig'] +``` + +There are 2 predefined themes available: + +- `@FormCollection/form_theme_div.html.twig` +- `@FormCollection/form_theme_table.html.twig` + +[Check the Symfony doc](https://symfony.com/doc/4.4/form/form_themes.html) for the different ways to set themes in Symfony. + +## Use a custom form theme + +Consider your `BlogFormType` form set up and with a comments field that is a `CollectionType`, you can +render it in your template: + +```twig +{% macro commentFormRow(commentForm) %} +
+ {{ form_errors(commentForm) }} + {{ form_row(commentForm.content) }} + {{ form_row(commentForm.otherField) }} + + +
+{% endmacro %} + +
+ {% for commentForm in form.comments %} + {{ _self.commentFormRow(commentForm) }} + {% endfor %} + + +
+``` + +## Usage + +The most common usage of Form Collection is to use it as a replacement of +the native CollectionType class: + +```php +// ... +use Symfony\UX\FormCollection\Form\UXCollectionType; + +class BlogFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + // ... + ->add('comments', UXCollectionType::class, [ + // ... + 'button_add_options' => [ + 'label' => 'Add', // Default text for the add button (used by predefined theme) + 'class' => 'btn btn-outline-primary', // Add HTML classes to the add button (used by predefined theme) + ], + 'button_delete_options' => [ + 'label' => 'Remove', // Default text for the delete button (used by predefined theme) + 'class' => 'btn btn-outline-secondary', // Add HTML classes to the add button (used by predefined theme) + ], + ]) + // ... + ; + } + + // ... +} +``` + +You can display it using Twig as you would normally with any form: + +```twig +{# edit.html.twig #} + +{{ form_start(form) }} + {# ... #} + {{ form_row(comments) }} + {# ... #} +{{ form_end(form) }} +``` + +### Extend the default behavior + +Symfony UX Form Collection allows you to extend its default behavior using a custom Stimulus controller: + +```js +// mycollection_controller.js + +import { Controller } from 'stimulus'; + +export default class extends Controller { + connect() { + this.element.addEventListener('collection:pre-connect', this._onPreConnect); + this.element.addEventListener('collection:connect', this._onConnect); + this.element.addEventListener('collection:pre-add', this._onPreAdd); + this.element.addEventListener('collection:add', this._onAdd); + this.element.addEventListener('collection:pre-delete', this._onPreDelete); + this.element.addEventListener('collection:delete', this._onDelete); + } + + disconnect() { + // You should always remove listeners when the controller is disconnected to avoid side effects + this.element.removeEventListener('collection:pre-connect', this._onPreConnect); + this.element.removeEventListener('collection:connect', this._onConnect); + } + + _onPreConnect(event) { + // The collection is not yet connected + console.log(event.detail.allowAdd); // Access to the allow_add option of the form + console.log(event.detail.allowDelete); // Access to the allow_delete option of the form + } + + _onConnect(event) { + // Same as collection:pre-connect event + } + + _onPreAdd(event) { + console.log(event.detail.index); // Access to the curent index will be added + console.log(event.detail.element); // Access to the element will be added + } + + _onAdd(event) { + // Same as collection:pre-add event + } + + _onPreDelete(event) { + console.log(event.detail.index); // Access to the index will be removed + console.log(event.detail.element); // Access to the elemnt will be removed + } + + _onDelete(event) { + // Same as collection:pre-delete event + } +} +``` + +Then in your render call, add your controller as an HTML attribute: + +```php + $builder + // ... + ->add('comments', UXCollectionType::class, [ + // ... + 'attr' => [ + // Change the controller name + 'data-controller' => 'mycollection' + ] + ]); +``` diff --git a/src/FormCollection/Resources/assets/.babelrc b/src/FormCollection/Resources/assets/.babelrc new file mode 100644 index 00000000000..77f182d2ca8 --- /dev/null +++ b/src/FormCollection/Resources/assets/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["@babel/env"], + "plugins": ["@babel/plugin-proposal-class-properties"] +} diff --git a/src/FormCollection/Resources/assets/dist/controller.js b/src/FormCollection/Resources/assets/dist/controller.js new file mode 100644 index 00000000000..8e5ae980139 --- /dev/null +++ b/src/FormCollection/Resources/assets/dist/controller.js @@ -0,0 +1,105 @@ +import { Controller } from '@hotwired/stimulus'; + +class default_1 extends Controller { + constructor() { + super(...arguments); + this.index = 0; + } + connect() { + this.controllerName = this.context.scope.identifier; + this.index = this.startIndexValue ? this.startIndexValue : this.entryTargets.length - 1; + if (!this.prototypeNameValue) { + this.prototypeNameValue = '__name__'; + } + this._dispatchEvent('form-collection:pre-connect', { + allowAdd: this.allowAddValue, + allowDelete: this.allowDeleteValue, + }); + if (this.allowAddValue) { + const buttonAdd = this._textToNode(this.buttonAddValue); + this.element.prepend(buttonAdd); + } + if (this.allowDeleteValue) { + for (let i = 0; i < this.entryTargets.length; i++) { + const entry = this.entryTargets[i]; + this._addDeleteButton(entry, i); + } + } + this._dispatchEvent('form-collection:connect', { + allowAdd: this.allowAddValue, + allowDelete: this.allowDeleteValue, + }); + } + add() { + this.index++; + let newEntry = this.element.dataset.prototype; + if (!newEntry) { + newEntry = this.prototypeValue; + } + let regExp = new RegExp(this.prototypeNameValue + 'label__', 'g'); + newEntry = newEntry.replace(regExp, this.index); + regExp = new RegExp(this.prototypeNameValue, 'g'); + newEntry = newEntry.replace(regExp, this.index); + newEntry = this._textToNode(newEntry); + this._dispatchEvent('form-collection:pre-add', { + index: this.index, + element: newEntry, + }); + this.element.append(newEntry); + let entry = this.entryTargets[this.entryTargets.length - 1]; + entry = this._addDeleteButton(entry, this.index); + this._dispatchEvent('form-collection:add', { + index: this.index, + element: entry, + }); + } + delete(event) { + const entry = event.target.closest('[data-' + this.controllerName + '-target="entry"]'); + this._dispatchEvent('form-collection:pre-delete', { + index: entry.dataset.indexEntry, + element: entry, + }); + entry.remove(); + this._dispatchEvent('form-collection:delete', { + index: entry.dataset.indexEntry, + element: entry, + }); + } + _addDeleteButton(entry, index) { + entry.dataset.indexEntry = index.toString(); + const buttonDelete = this._textToNode(this.buttonDeleteValue); + if (!buttonDelete) { + return entry; + } + buttonDelete.dataset.indexEntry = index; + if ('TR' === entry.nodeName) { + entry.lastElementChild.append(buttonDelete); + } + else { + entry.append(buttonDelete); + } + return entry; + } + _textToNode(text) { + const template = document.createElement('template'); + text = text.trim(); + template.innerHTML = text; + return template.content.firstChild; + } + _dispatchEvent(name, payload) { + console.log('TTTT'); + this.element.dispatchEvent(new CustomEvent(name, { detail: payload })); + } +} +default_1.targets = ['entry']; +default_1.values = { + allowAdd: Boolean, + allowDelete: Boolean, + buttonAdd: String, + buttonDelete: String, + prototypeName: String, + prototype: String, + startIndex: Number, +}; + +export { default_1 as default }; diff --git a/src/FormCollection/Resources/assets/jest.config.js b/src/FormCollection/Resources/assets/jest.config.js new file mode 100644 index 00000000000..5cc9ba23dab --- /dev/null +++ b/src/FormCollection/Resources/assets/jest.config.js @@ -0,0 +1,5 @@ +const config = require('../../../../jest.config.js'); + +config.setupFilesAfterEnv.push('./test/setup.js'); + +module.exports = config; diff --git a/src/FormCollection/Resources/assets/package.json b/src/FormCollection/Resources/assets/package.json new file mode 100644 index 00000000000..bccdc162439 --- /dev/null +++ b/src/FormCollection/Resources/assets/package.json @@ -0,0 +1,22 @@ +{ + "name": "@symfony/ux-form-collection", + "description": "UX Form Collection for Symfony Forms", + "license": "MIT", + "version": "1.0.0", + "symfony": { + "controllers": { + "collection": { + "main": "dist/controller.js", + "webpackMode": "eager", + "fetch": "eager", + "enabled": true + } + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0" + }, + "devDependencies": { + "@hotwired/stimulus": "^3.0.0" + } +} diff --git a/src/FormCollection/Resources/assets/src/controller.ts b/src/FormCollection/Resources/assets/src/controller.ts new file mode 100644 index 00000000000..eebd4ab9e9d --- /dev/null +++ b/src/FormCollection/Resources/assets/src/controller.ts @@ -0,0 +1,156 @@ +'use strict'; + +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['entry']; + + static values = { + allowAdd: Boolean, + allowDelete: Boolean, + buttonAdd: String, + buttonDelete: String, + prototypeName: String, + prototype: String, + startIndex: Number, + }; + + allowAddValue: boolean; + allowDeleteValue: boolean; + buttonAddValue: string; + buttonDeleteValue: string; + prototypeNameValue: string; + prototypeValue: string; + startIndexValue: number; + + /** + * Number of elements for the index of the collection + */ + index = 0; + + controllerName: string; + + entryTargets: Array = []; + + connect() { + this.controllerName = this.context.scope.identifier; + this.index = this.startIndexValue ? this.startIndexValue : this.entryTargets.length - 1; + + if (!this.prototypeNameValue) { + this.prototypeNameValue = '__name__'; + } + + this._dispatchEvent('form-collection:pre-connect', { + allowAdd: this.allowAddValue, + allowDelete: this.allowDeleteValue, + }); + + if (this.allowAddValue) { + // Add button Add + const buttonAdd = this._textToNode(this.buttonAddValue); + this.element.prepend(buttonAdd); + } + + // Add buttons Delete + if (this.allowDeleteValue) { + for (let i = 0; i < this.entryTargets.length; i++) { + const entry = this.entryTargets[i]; + this._addDeleteButton(entry, i); + } + } + + this._dispatchEvent('form-collection:connect', { + allowAdd: this.allowAddValue, + allowDelete: this.allowDeleteValue, + }); + } + + add() { + this.index++; + + // Compute the new entry + let newEntry = this.element.dataset.prototype; + if (!newEntry) { + newEntry = this.prototypeValue; + } + + let regExp = new RegExp(this.prototypeNameValue + 'label__', 'g'); + newEntry = newEntry.replace(regExp, this.index); + + regExp = new RegExp(this.prototypeNameValue, 'g'); + newEntry = newEntry.replace(regExp, this.index); + + newEntry = this._textToNode(newEntry); + + this._dispatchEvent('form-collection:pre-add', { + index: this.index, + element: newEntry, + }); + + this.element.append(newEntry); + + // Retrieve the entry from targets to make sure that this is the one + let entry = this.entryTargets[this.entryTargets.length - 1]; + entry = this._addDeleteButton(entry, this.index); + + this._dispatchEvent('form-collection:add', { + index: this.index, + element: entry, + }); + } + + delete(event) { + const entry = event.target.closest('[data-' + this.controllerName + '-target="entry"]'); + + this._dispatchEvent('form-collection:pre-delete', { + index: entry.dataset.indexEntry, + element: entry, + }); + + entry.remove(); + + this._dispatchEvent('form-collection:delete', { + index: entry.dataset.indexEntry, + element: entry, + }); + } + + /** + * Add the delete button to the entry + * @private + */ + _addDeleteButton(entry: HTMLElement, index: number) { + // link the button and the entry by the data-index-entry attribute + entry.dataset.indexEntry = index.toString(); + + const buttonDelete = this._textToNode(this.buttonDeleteValue); + if (!buttonDelete) { + return entry; + } + buttonDelete.dataset.indexEntry = index; + + if ('TR' === entry.nodeName) { + entry.lastElementChild.append(buttonDelete); + } else { + entry.append(buttonDelete); + } + + return entry; + } + + /** + * Convert text to Element to insert in the DOM + * @private + */ + _textToNode(text: string) { + const template = document.createElement('template'); + text = text.trim(); // Never return a text node of whitespace as the result + template.innerHTML = text; + + return template.content.firstChild; + } + + _dispatchEvent(name: string, payload: any) { + this.element.dispatchEvent(new CustomEvent(name, { detail: payload })); + } +} diff --git a/src/FormCollection/Resources/assets/test/controller.test.ts b/src/FormCollection/Resources/assets/test/controller.test.ts new file mode 100644 index 00000000000..bad41d3fbf4 --- /dev/null +++ b/src/FormCollection/Resources/assets/test/controller.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { Application, Controller } from '@hotwired/stimulus'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import FormCollectionController from '../src/controller'; + +// Controller used to check the actual controller was properly booted +class CheckController extends Controller { + connect() { + this.element.addEventListener('form-collection:pre-connect', () => { + this.element.classList.add('pre-connected'); + }); + this.element.addEventListener('form-collection:connect', () => { + this.element.classList.add('connected'); + }); + } +} + +const startStimulus = () => { + const application = Application.start(); + application.register('check', CheckController); + application.register('formCollection', FormCollectionController); + + return application; +}; + +describe('FormCollectionController', () => { + let application; + + afterEach(() => { + clearDOM(); + application.stop(); + }); + + it('events', async () => { + const container = mountDOM(` +
+
+ `); + + expect(getByTestId(container, 'container')).not.toHaveClass('pre-connected'); + expect(getByTestId(container, 'container')).not.toHaveClass('connected'); + + application = startStimulus(); + + await waitFor(() => { + expect(getByTestId(container, 'container')).toHaveClass('pre-connected') + expect(getByTestId(container, 'container')).toHaveClass('connected') + }); + }); +}); diff --git a/src/FormCollection/Resources/assets/test/setup.js b/src/FormCollection/Resources/assets/test/setup.js new file mode 100644 index 00000000000..ddd4655c30e --- /dev/null +++ b/src/FormCollection/Resources/assets/test/setup.js @@ -0,0 +1,12 @@ +/* + * 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 '@symfony/stimulus-testing/setup'; diff --git a/src/FormCollection/Resources/views/form_theme_div.html.twig b/src/FormCollection/Resources/views/form_theme_div.html.twig new file mode 100644 index 00000000000..9c74d0f9140 --- /dev/null +++ b/src/FormCollection/Resources/views/form_theme_div.html.twig @@ -0,0 +1,51 @@ +{%- block button_add -%} + {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#add')|trim -%} + +{%- endblock button_add -%} + +{%- block button_delete -%} + {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#delete')|trim -%} + +{%- endblock button_delete -%} + +{% block ux_collection_widget -%} + {%- set controllerName = 'symfony--ux-form-collection--collection' -%} + {%- set dataController = (attr['data-controller']|default('') ~ ' ' ~ controllerName)|trim -%} + + {# attr for the data target on the entry of the collection #} + {%- set attrDataTarget = {('data-' ~ controllerName ~ '-target'): 'entry' } -%} + + {% if prototype is defined and not prototype.rendered %} + {%- set prototype_attr = prototype.vars.row_attr|merge(attrDataTarget) -%} + {%- set attr = attr|merge({'data-prototype': form_row(prototype, {'row_attr': prototype_attr}) }) -%} + {% endif %} + {%- set attr = attr|merge({('data-' ~ controllerName ~ '-target'): 'container' }) -%} + + {% set indexKeys = data|keys %} + {% set startIndex = indexKeys|length == 0 ? 0 : max(indexKeys) %} + +
+ {%- if form is rootform -%} + {{ form_errors(form) }} + {%- endif -%} + + {% for child in form|filter(child => not child.rendered) %} + + {%- set child_attr = child.vars.row_attr|merge(attrDataTarget) -%} + {{- form_row(child, {'row_attr': child_attr}) -}} + + {% endfor %} + + {{- form_rest(form) -}} +
+{%- endblock %} diff --git a/src/FormCollection/Resources/views/form_theme_table.html.twig b/src/FormCollection/Resources/views/form_theme_table.html.twig new file mode 100644 index 00000000000..59aacd3f759 --- /dev/null +++ b/src/FormCollection/Resources/views/form_theme_table.html.twig @@ -0,0 +1,55 @@ +{%- block button_add -%} + {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#add')|trim -%} + + + + + +{%- endblock button_add -%} + +{%- block button_delete -%} + {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#delete')|trim -%} + +{%- endblock button_delete -%} + +{% block ux_collection_widget -%} + {%- set controllerName = 'symfony--ux-form-collection--collection' -%} + {%- set dataController = (attr['data-controller']|default('') ~ ' ' ~ controllerName)|trim -%} + + {# attr for the data target on the entry of the collection #} + {%- set attrDataTarget = {('data-' ~ controllerName ~ '-target'): 'entry' } -%} + + {% if prototype is defined and not prototype.rendered %} + {%- set prototype_attr = prototype.vars.row_attr|merge(attrDataTarget) -%} + {%- set attr = attr|merge({'data-prototype': form_row(prototype, {'row_attr': prototype_attr}) }) -%} + {% endif %} + + {% set indexKeys = data|keys %} + {% set startIndex = indexKeys|length == 0 ? 0 : max(indexKeys) %} + +
+ + {%- if form is rootform -%} + {{ form_errors(form) }} + {%- endif -%} + + {% for child in form|filter(child => not child.rendered) %} + + {%- set child_attr = child.vars.row_attr|merge(attrDataTarget) -%} + {{- form_row(child, {'row_attr': child_attr}) -}} + + {% endfor %} +
+ {{- form_rest(form) -}} +
+{%- endblock %} diff --git a/src/FormCollection/composer.json b/src/FormCollection/composer.json new file mode 100644 index 00000000000..181b5cbf466 --- /dev/null +++ b/src/FormCollection/composer.json @@ -0,0 +1,52 @@ +{ + "name": "symfony/ux-form-collection", + "type": "symfony-bundle", + "description": "UX Form Collection for Symfony Forms", + "keywords": [ + "symfony-ux", + "ux-form-collection" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Stakovicz", + "email": "stakovicz@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\FormCollection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "require": { + "php": ">=7.2.5", + "symfony/config": "^4.4.17|^5.0", + "symfony/dependency-injection": "^4.4.17|^5.0", + "symfony/form": "^4.4.17|^5.0", + "symfony/http-kernel": "^4.4.17|^5.0" + }, + "require-dev": { + "symfony/framework-bundle": "^4.4.17|^5.0", + "symfony/phpunit-bridge": "^5.2", + "symfony/twig-bundle": "^4.4.17|^5.0", + "symfony/var-dumper": "^4.4.17|^5.0" + }, + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + }, + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +}