From dbba3b839525ad5ba520f229c1a982b1da261bd9 Mon Sep 17 00:00:00 2001 From: Albin <83301974+stakovicz@users.noreply.github.com> Date: Mon, 3 May 2021 11:59:15 +0200 Subject: [PATCH 01/34] UX FormCollection --- src/FormCollection/.gitattributes | 2 + .../FormCollectionExtension.php | 37 ++++ src/FormCollection/Form/CollectionType.php | 57 +++++ src/FormCollection/FormCollectionBundle.php | 22 ++ src/FormCollection/LICENSE | 19 ++ src/FormCollection/README.md | 141 ++++++++++++ src/FormCollection/Resources/assets/.babelrc | 4 + .../Resources/assets/.gitignore | 1 + .../Resources/assets/dist/controller.js | 203 ++++++++++++++++++ .../Resources/assets/package.json | 32 +++ .../Resources/assets/src/controller.js | 156 ++++++++++++++ .../Resources/views/form_theme_div.html.twig | 47 ++++ .../views/form_theme_table.html.twig | 51 +++++ src/FormCollection/composer.json | 52 +++++ 14 files changed, 824 insertions(+) create mode 100644 src/FormCollection/.gitattributes create mode 100644 src/FormCollection/DependencyInjection/FormCollectionExtension.php create mode 100644 src/FormCollection/Form/CollectionType.php create mode 100644 src/FormCollection/FormCollectionBundle.php create mode 100644 src/FormCollection/LICENSE create mode 100644 src/FormCollection/README.md create mode 100644 src/FormCollection/Resources/assets/.babelrc create mode 100644 src/FormCollection/Resources/assets/.gitignore create mode 100644 src/FormCollection/Resources/assets/dist/controller.js create mode 100644 src/FormCollection/Resources/assets/package.json create mode 100644 src/FormCollection/Resources/assets/src/controller.js create mode 100644 src/FormCollection/Resources/views/form_theme_div.html.twig create mode 100644 src/FormCollection/Resources/views/form_theme_table.html.twig create mode 100644 src/FormCollection/composer.json diff --git a/src/FormCollection/.gitattributes b/src/FormCollection/.gitattributes new file mode 100644 index 00000000000..dfe0770424b --- /dev/null +++ b/src/FormCollection/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/src/FormCollection/DependencyInjection/FormCollectionExtension.php b/src/FormCollection/DependencyInjection/FormCollectionExtension.php new file mode 100644 index 00000000000..208ecacf550 --- /dev/null +++ b/src/FormCollection/DependencyInjection/FormCollectionExtension.php @@ -0,0 +1,37 @@ + + * + * 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\UX\FormCollection\Form\CollectionType; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; + +/** + * @internal + */ +class FormCollectionExtension extends Extension implements PrependExtensionInterface +{ + public function prepend(ContainerBuilder $container) + { + } + + public function load(array $configs, ContainerBuilder $container) + { + $container + ->setDefinition('form.ux_collection', new Definition(CollectionType::class)) + ->addTag('form.type') + ->setPublic(false) + ; + } +} diff --git a/src/FormCollection/Form/CollectionType.php b/src/FormCollection/Form/CollectionType.php new file mode 100644 index 00000000000..120f9607cac --- /dev/null +++ b/src/FormCollection/Form/CollectionType.php @@ -0,0 +1,57 @@ + + * + * 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 as BaseCollectionType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @final + * @experimental + */ +class CollectionType extends AbstractType +{ + public function getParent() + { + return BaseCollectionType::class; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'button_add' => [ + 'text' => 'Add', + 'attr' => ['class' => 'btn btn-outline-primary'], + ], + 'button_delete' => [ + 'text' => 'Remove', + 'attr' => ['class' => 'btn btn-outline-secondary'], + ], + ]); + } + + public function finishView(FormView $view, FormInterface $form, array $options) + { + parent::finishView($view, $form, $options); + + $view->vars['button_add'] = $options['button_add']; + $view->vars['button_delete'] = $options['button_delete']; + } + + public function getBlockPrefix() + { + return 'form_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..c6f4e6125e6 --- /dev/null +++ b/src/FormCollection/README.md @@ -0,0 +1,141 @@ +# 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. + +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'] +``` +You have 2 different themes : +- `@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. + +## 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\CollectionType; + +class BlogFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + // ... + ->add('comments', CollectionType::class, [ + // ... + 'button_add' => [ + // Default text for the add button + 'text' => 'Add', + // Default attr class for the add button + 'attr' => ['class' => 'btn btn-outline-primary'] + ], + 'button_delete' => [ + // Default text for the delete button + 'text' => 'Remove', + // Default class for the delete button + 'attr' => ['class' => 'btn btn-outline-secondary'] + ], + ]) + // ... + ; + } + + // ... +} +``` + +### 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' + ] + ]); +``` \ No newline at end of file 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/.gitignore b/src/FormCollection/Resources/assets/.gitignore new file mode 100644 index 00000000000..096746c1480 --- /dev/null +++ b/src/FormCollection/Resources/assets/.gitignore @@ -0,0 +1 @@ +/node_modules/ \ No newline at end of file diff --git a/src/FormCollection/Resources/assets/dist/controller.js b/src/FormCollection/Resources/assets/dist/controller.js new file mode 100644 index 00000000000..45685d3234f --- /dev/null +++ b/src/FormCollection/Resources/assets/dist/controller.js @@ -0,0 +1,203 @@ +'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"); + +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 = /*#__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), "index", 0); + + _defineProperty(_assertThisInitialized(_this), "controllerName", null); + + return _this; + } + + _createClass(_default, [{ + key: "connect", + value: function connect() { + this.controllerName = this.context.scope.identifier; + + this._dispatchEvent('form-collection:pre-connect', { + allowAdd: this.allowAddValue, + allowDelete: this.allowDeleteValue + }); + + if (true === this.allowAddValue) { + // Add button Add + var buttonAdd = this._textToNode(this.buttonAddValue); + + this.containerTarget.prepend(buttonAdd); + } // Add buttons Delete + + + if (true === this.allowDeleteValue) { + for (var i = 0; i < this.entryTargets.length; i++) { + this.index = i; + var entry = this.entryTargets[i]; + + this._addDeleteButton(entry, this.index); + } + } + + this._dispatchEvent('form-collection:connect', { + allowAdd: this.allowAddValue, + allowDelete: this.allowDeleteValue + }); + } + }, { + key: "add", + value: function add(event) { + this.index++; // Compute the new entry + + var newEntry = this.containerTarget.dataset.prototype; + newEntry = newEntry.replace(/__name__label__/g, this.index); + newEntry = newEntry.replace(/__name__/g, this.index); + newEntry = this._textToNode(newEntry); + + this._dispatchEvent('form-collection:pre-add', { + index: this.index, + element: newEntry + }); + + this.containerTarget.append(newEntry); // Retrieve the entry from targets to make sure that this is the one + + var entry = this.entryTargets[this.entryTargets.length - 1]; + entry = this._addDeleteButton(entry, this.index); + + this._dispatchEvent('form-collection:add', { + index: this.index, + element: entry + }); + } + }, { + key: "delete", + value: function _delete(event) { + var theIndexEntryToDelete = event.target.dataset.indexEntry; // Search the entry to delete from the data-index-entry attribute + + for (var i = 0; i < this.entryTargets.length; i++) { + var entry = this.entryTargets[i]; + + if (theIndexEntryToDelete === entry.dataset.indexEntry) { + 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 + * @param String entry + * @param Number index + * @returns {ChildNode} + * @private + */ + + }, { + key: "_addDeleteButton", + value: function _addDeleteButton(entry, index) { + // link the button and the entry by the data-index-entry attribute + entry.dataset.indexEntry = index; + + var buttonDelete = this._textToNode(this.buttonDeleteValue); + + 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 + * @param String text + * @returns {ChildNode} + * @private + */ + + }, { + key: "_textToNode", + value: function _textToNode(text) { + var template = document.createElement('template'); + text = text.trim(); // Never return a text node of whitespace as the result + + template.innerHTML = text; + return template.content.firstChild; + } + }, { + 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] : false; + var cancelable = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + var userEvent = document.createEvent('CustomEvent'); + userEvent.initCustomEvent(name, canBubble, cancelable, payload); + this.element.dispatchEvent(userEvent); + } + }]); + + return _default; +}(_stimulus.Controller); + +exports["default"] = _default; + +_defineProperty(_default, "targets", ['container', 'entry']); + +_defineProperty(_default, "values", { + allowAdd: Boolean, + allowDelete: Boolean, + buttonAdd: String, + buttonDelete: String +}); \ No newline at end of file diff --git a/src/FormCollection/Resources/assets/package.json b/src/FormCollection/Resources/assets/package.json new file mode 100644 index 00000000000..b821fddc88a --- /dev/null +++ b/src/FormCollection/Resources/assets/package.json @@ -0,0 +1,32 @@ +{ + "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 + } + } + }, + "scripts": { + "build": "babel src -d dist", + "test": "babel src -d dist && jest", + "lint": "eslint src test" + }, + "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", + "stimulus": "^2.0.0" + } +} diff --git a/src/FormCollection/Resources/assets/src/controller.js b/src/FormCollection/Resources/assets/src/controller.js new file mode 100644 index 00000000000..d65f29c784a --- /dev/null +++ b/src/FormCollection/Resources/assets/src/controller.js @@ -0,0 +1,156 @@ +'use strict'; + +import { Controller } from 'stimulus'; + +export default class extends Controller { + static targets = [ + 'container', + 'entry' + ]; + + static values = { + allowAdd: Boolean, + allowDelete: Boolean, + buttonAdd: String, + buttonDelete: String + }; + + /** + * Number of elements for the index of the collection + * @type Number + */ + index = 0; + + /** + * Controller name of this + * @type String|null + */ + controllerName = null; + + connect() { + this.controllerName = this.context.scope.identifier; + + + this._dispatchEvent('form-collection:pre-connect', { + allowAdd: this.allowAddValue, + allowDelete: this.allowDeleteValue, + }); + + if (true === this.allowAddValue) { + // Add button Add + let buttonAdd = this._textToNode(this.buttonAddValue); + this.containerTarget.prepend(buttonAdd); + } + + // Add buttons Delete + if (true === this.allowDeleteValue) { + for (let i = 0; i < this.entryTargets.length; i++) { + this.index = i; + let entry = this.entryTargets[i]; + this._addDeleteButton(entry, this.index); + } + } + + this._dispatchEvent('form-collection:connect', { + allowAdd: this.allowAddValue, + allowDelete: this.allowDeleteValue, + }); + } + + add(event) { + + this.index++; + + // Compute the new entry + let newEntry = this.containerTarget.dataset.prototype; + newEntry = newEntry.replace(/__name__label__/g, this.index); + newEntry = newEntry.replace(/__name__/g, this.index); + newEntry = this._textToNode(newEntry); + + this._dispatchEvent('form-collection:pre-add', { + index: this.index, + element: newEntry + }); + + this.containerTarget.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) { + + let theIndexEntryToDelete = event.target.dataset.indexEntry; + + // Search the entry to delete from the data-index-entry attribute + for (let i = 0; i < this.entryTargets.length; i++) { + let entry = this.entryTargets[i]; + if (theIndexEntryToDelete === entry.dataset.indexEntry) { + + 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 + * @param String entry + * @param Number index + * @returns {ChildNode} + * @private + */ + _addDeleteButton(entry, index) { + + // link the button and the entry by the data-index-entry attribute + entry.dataset.indexEntry = index; + + let buttonDelete = this._textToNode(this.buttonDeleteValue); + 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 + * @param String text + * @returns {ChildNode} + * @private + */ + _textToNode(text) { + + let 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, payload = null, canBubble = false, cancelable = false) { + const userEvent = document.createEvent('CustomEvent'); + userEvent.initCustomEvent(name, canBubble, cancelable, payload); + + this.element.dispatchEvent(userEvent); + } +} 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..6ddc3282152 --- /dev/null +++ b/src/FormCollection/Resources/views/form_theme_div.html.twig @@ -0,0 +1,47 @@ +{%- 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 form_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.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' }) -%} + +
+ {%- if form is rootform -%} + {{ form_errors(form) }} + {%- endif -%} + + {% for child in form|filter(child => not child.rendered) %} + + {%- set child_attr = child.vars.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..75c5745b1ab --- /dev/null +++ b/src/FormCollection/Resources/views/form_theme_table.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 form_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.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' }) -%} + + + {%- if form is rootform -%} + {{ form_errors(form) }} + {%- endif -%} + + {% for child in form|filter(child => not child.rendered) %} + + {%- set child_attr = child.vars.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" +} From 83ec2ead9a9251b739ecdc426ef57f98bffa95aa Mon Sep 17 00:00:00 2001 From: Albin <83301974+stakovicz@users.noreply.github.com> Date: Tue, 4 May 2021 09:13:00 +0200 Subject: [PATCH 02/34] Remove hard coded property_name __name__ --- src/FormCollection/Form/CollectionType.php | 1 + .../Resources/assets/dist/controller.js | 9 ++++++--- .../Resources/assets/src/controller.js | 12 +++++++++--- .../Resources/views/form_theme_div.html.twig | 1 + 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/FormCollection/Form/CollectionType.php b/src/FormCollection/Form/CollectionType.php index 120f9607cac..1e31499a88b 100644 --- a/src/FormCollection/Form/CollectionType.php +++ b/src/FormCollection/Form/CollectionType.php @@ -48,6 +48,7 @@ public function finishView(FormView $view, FormInterface $form, array $options) $view->vars['button_add'] = $options['button_add']; $view->vars['button_delete'] = $options['button_delete']; + $view->vars['prototype_name'] = $options['prototype_name']; } public function getBlockPrefix() diff --git a/src/FormCollection/Resources/assets/dist/controller.js b/src/FormCollection/Resources/assets/dist/controller.js index 45685d3234f..271c7ca00e2 100644 --- a/src/FormCollection/Resources/assets/dist/controller.js +++ b/src/FormCollection/Resources/assets/dist/controller.js @@ -92,8 +92,10 @@ var _default = /*#__PURE__*/function (_Controller) { this.index++; // Compute the new entry var newEntry = this.containerTarget.dataset.prototype; - newEntry = newEntry.replace(/__name__label__/g, this.index); - newEntry = newEntry.replace(/__name__/g, this.index); + var 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', { @@ -199,5 +201,6 @@ _defineProperty(_default, "values", { allowAdd: Boolean, allowDelete: Boolean, buttonAdd: String, - buttonDelete: String + buttonDelete: String, + prototypeName: String }); \ No newline at end of file diff --git a/src/FormCollection/Resources/assets/src/controller.js b/src/FormCollection/Resources/assets/src/controller.js index d65f29c784a..72311e4ef4e 100644 --- a/src/FormCollection/Resources/assets/src/controller.js +++ b/src/FormCollection/Resources/assets/src/controller.js @@ -12,7 +12,8 @@ export default class extends Controller { allowAdd: Boolean, allowDelete: Boolean, buttonAdd: String, - buttonDelete: String + buttonDelete: String, + prototypeName: String }; /** @@ -63,8 +64,13 @@ export default class extends Controller { // Compute the new entry let newEntry = this.containerTarget.dataset.prototype; - newEntry = newEntry.replace(/__name__label__/g, this.index); - newEntry = newEntry.replace(/__name__/g, this.index); + + 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', { diff --git a/src/FormCollection/Resources/views/form_theme_div.html.twig b/src/FormCollection/Resources/views/form_theme_div.html.twig index 6ddc3282152..47f2a3c5b24 100644 --- a/src/FormCollection/Resources/views/form_theme_div.html.twig +++ b/src/FormCollection/Resources/views/form_theme_div.html.twig @@ -23,6 +23,7 @@ data-{{ controllerName }}-allow-delete-value="{{ allow_delete|json_encode }}" data-{{ controllerName }}-button-add-value="{{ block('button_add')|e }}" data-{{ controllerName }}-button-delete-value="{{ block('button_delete')|e }}" + data-{{ controllerName }}-prototype-name-value="{{ prototype_name }}" > {% if prototype is defined and not prototype.rendered %} {%- set prototype_attr = prototype.vars.attr|merge(attrDataTarget) -%} From d983cf0a7c1a359455cf365065a0af6a046000db Mon Sep 17 00:00:00 2001 From: Albin <83301974+stakovicz@users.noreply.github.com> Date: Tue, 4 May 2021 18:37:35 +0200 Subject: [PATCH 03/34] PHP CS Fixer --- .../DependencyInjection/FormCollectionExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FormCollection/DependencyInjection/FormCollectionExtension.php b/src/FormCollection/DependencyInjection/FormCollectionExtension.php index 208ecacf550..6959667f1da 100644 --- a/src/FormCollection/DependencyInjection/FormCollectionExtension.php +++ b/src/FormCollection/DependencyInjection/FormCollectionExtension.php @@ -11,11 +11,11 @@ namespace Symfony\UX\FormCollection\DependencyInjection; -use Symfony\UX\FormCollection\Form\CollectionType; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\UX\FormCollection\Form\CollectionType; /** * @internal From a35ff32b660803ef629c1ee49790833cbe4117a7 Mon Sep 17 00:00:00 2001 From: Albin <83301974+stakovicz@users.noreply.github.com> Date: Tue, 4 May 2021 20:59:20 +0200 Subject: [PATCH 04/34] First jests --- .../Resources/assets/package.json | 6 ++ .../Resources/assets/test/controller.test.js | 60 +++++++++++++++++++ .../Resources/assets/test/setup.js | 12 ++++ 3 files changed, 78 insertions(+) create mode 100644 src/FormCollection/Resources/assets/test/controller.test.js create mode 100644 src/FormCollection/Resources/assets/test/setup.js diff --git a/src/FormCollection/Resources/assets/package.json b/src/FormCollection/Resources/assets/package.json index b821fddc88a..f30194403c6 100644 --- a/src/FormCollection/Resources/assets/package.json +++ b/src/FormCollection/Resources/assets/package.json @@ -28,5 +28,11 @@ "@babel/preset-env": "^7.12.7", "@symfony/stimulus-testing": "^1.1.0", "stimulus": "^2.0.0" + }, + "jest": { + "testRegex": "test/.*\\.test.js", + "setupFilesAfterEnv": [ + "./test/setup.js" + ] } } diff --git a/src/FormCollection/Resources/assets/test/controller.test.js b/src/FormCollection/Resources/assets/test/controller.test.js new file mode 100644 index 00000000000..f764a48d6e8 --- /dev/null +++ b/src/FormCollection/Resources/assets/test/controller.test.js @@ -0,0 +1,60 @@ +/* + * 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 'stimulus'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import user from '@testing-library/user-event'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import FormCollectionController from '../dist/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); +}; + +describe('FormCollectionController', () => { + let container; + + beforeEach(() => { + container = mountDOM(` +
+ +
+ `); + }); + + afterEach(() => { + clearDOM(); + }); + + it('events', async () => { + expect(getByTestId(container, 'container')).not.toHaveClass('connected'); + expect(getByTestId(container, 'container')).not.toHaveClass('pre-connected'); + + startStimulus(); + await waitFor(() => expect(getByTestId(container, 'container')).toHaveClass('connected')); + await waitFor(() => expect(getByTestId(container, 'container')).toHaveClass('pre-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'; From e1d7574d444f706fc67aee74b1624d2324563745 Mon Sep 17 00:00:00 2001 From: Albin <83301974+stakovicz@users.noreply.github.com> Date: Thu, 6 May 2021 20:50:31 +0200 Subject: [PATCH 05/34] Rename CollectionType > UXCollectionType --- .../DependencyInjection/FormCollectionExtension.php | 4 ++-- .../{CollectionType.php => UXCollectionType.php} | 12 +++++------- src/FormCollection/README.md | 4 ++-- 3 files changed, 9 insertions(+), 11 deletions(-) rename src/FormCollection/Form/{CollectionType.php => UXCollectionType.php} (76%) diff --git a/src/FormCollection/DependencyInjection/FormCollectionExtension.php b/src/FormCollection/DependencyInjection/FormCollectionExtension.php index 6959667f1da..7eb856a7b5c 100644 --- a/src/FormCollection/DependencyInjection/FormCollectionExtension.php +++ b/src/FormCollection/DependencyInjection/FormCollectionExtension.php @@ -15,7 +15,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; -use Symfony\UX\FormCollection\Form\CollectionType; +use Symfony\UX\FormCollection\Form\UXCollectionType; /** * @internal @@ -29,7 +29,7 @@ public function prepend(ContainerBuilder $container) public function load(array $configs, ContainerBuilder $container) { $container - ->setDefinition('form.ux_collection', new Definition(CollectionType::class)) + ->setDefinition('form.ux_collection', new Definition(UXCollectionType::class)) ->addTag('form.type') ->setPublic(false) ; diff --git a/src/FormCollection/Form/CollectionType.php b/src/FormCollection/Form/UXCollectionType.php similarity index 76% rename from src/FormCollection/Form/CollectionType.php rename to src/FormCollection/Form/UXCollectionType.php index 1e31499a88b..6ef3390484d 100644 --- a/src/FormCollection/Form/CollectionType.php +++ b/src/FormCollection/Form/UXCollectionType.php @@ -12,7 +12,7 @@ namespace Symfony\UX\FormCollection\Form; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\CollectionType as BaseCollectionType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -21,23 +21,21 @@ * @final * @experimental */ -class CollectionType extends AbstractType +class UXCollectionType extends AbstractType { public function getParent() { - return BaseCollectionType::class; + return CollectionType::class; } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'button_add' => [ - 'text' => 'Add', - 'attr' => ['class' => 'btn btn-outline-primary'], + 'text' => 'Add' ], 'button_delete' => [ - 'text' => 'Remove', - 'attr' => ['class' => 'btn btn-outline-secondary'], + 'text' => 'Remove' ], ]); } diff --git a/src/FormCollection/README.md b/src/FormCollection/README.md index c6f4e6125e6..f7c1b58a12a 100644 --- a/src/FormCollection/README.md +++ b/src/FormCollection/README.md @@ -40,7 +40,7 @@ the native CollectionType class: ```php // ... -use Symfony\UX\FormCollection\Form\CollectionType; +use Symfony\UX\FormCollection\Form\UXCollectionType; class BlogFormType extends AbstractType { @@ -48,7 +48,7 @@ class BlogFormType extends AbstractType { $builder // ... - ->add('comments', CollectionType::class, [ + ->add('comments', UXCollectionType::class, [ // ... 'button_add' => [ // Default text for the add button From 409c8e7e4b89df897da2acf1d0f89fdb22a77900 Mon Sep 17 00:00:00 2001 From: Albin <83301974+stakovicz@users.noreply.github.com> Date: Thu, 6 May 2021 21:03:20 +0200 Subject: [PATCH 06/34] Rename CollectionType > UXCollectionType --- src/FormCollection/Form/UXCollectionType.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/FormCollection/Form/UXCollectionType.php b/src/FormCollection/Form/UXCollectionType.php index 6ef3390484d..02ff3c0c514 100644 --- a/src/FormCollection/Form/UXCollectionType.php +++ b/src/FormCollection/Form/UXCollectionType.php @@ -32,10 +32,12 @@ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'button_add' => [ - 'text' => 'Add' + 'text' => 'Add', + 'attr' => ['class' => 'btn btn-outline-primary'], ], 'button_delete' => [ - 'text' => 'Remove' + 'text' => 'Remove', + 'attr' => ['class' => 'btn btn-outline-secondary'], ], ]); } From a624f257e470980f7da00c07ff7b19f87943c045 Mon Sep 17 00:00:00 2001 From: Albin <83301974+stakovicz@users.noreply.github.com> Date: Thu, 6 May 2021 21:03:43 +0200 Subject: [PATCH 07/34] DependencyInjection Clean --- .../DependencyInjection/FormCollectionExtension.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/FormCollection/DependencyInjection/FormCollectionExtension.php b/src/FormCollection/DependencyInjection/FormCollectionExtension.php index 7eb856a7b5c..3266037fd37 100644 --- a/src/FormCollection/DependencyInjection/FormCollectionExtension.php +++ b/src/FormCollection/DependencyInjection/FormCollectionExtension.php @@ -13,19 +13,14 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; -use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\UX\FormCollection\Form\UXCollectionType; /** * @internal */ -class FormCollectionExtension extends Extension implements PrependExtensionInterface +class FormCollectionExtension extends Extension { - public function prepend(ContainerBuilder $container) - { - } - public function load(array $configs, ContainerBuilder $container) { $container From f4ba0118db973eb0526febf05d5590ea7e286131 Mon Sep 17 00:00:00 2001 From: Albin <83301974+stakovicz@users.noreply.github.com> Date: Thu, 6 May 2021 21:08:32 +0200 Subject: [PATCH 08/34] Fix .gitattributes --- src/FormCollection/.gitattributes | 5 +++-- src/FormCollection/Resources/assets/.gitignore | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 src/FormCollection/Resources/assets/.gitignore diff --git a/src/FormCollection/.gitattributes b/src/FormCollection/.gitattributes index dfe0770424b..17bf7a840e9 100644 --- a/src/FormCollection/.gitattributes +++ b/src/FormCollection/.gitattributes @@ -1,2 +1,3 @@ -# Auto detect text files and perform LF normalization -* text=auto +/.gitattributes export-ignore +/.gitignore export-ignore +/Resources/assets/test export-ignore diff --git a/src/FormCollection/Resources/assets/.gitignore b/src/FormCollection/Resources/assets/.gitignore deleted file mode 100644 index 096746c1480..00000000000 --- a/src/FormCollection/Resources/assets/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/node_modules/ \ No newline at end of file From 7741907030c362a60020cc4f91df1b3bd482b022 Mon Sep 17 00:00:00 2001 From: Albin <83301974+stakovicz@users.noreply.github.com> Date: Fri, 7 May 2021 21:03:09 +0200 Subject: [PATCH 09/34] Move default values From UXCollectionType to the view --- src/FormCollection/Form/UXCollectionType.php | 10 +++++----- src/FormCollection/README.md | 20 +++++++++++++++---- .../Resources/views/form_theme_div.html.twig | 18 ++++++++--------- .../views/form_theme_table.html.twig | 13 ++++++------ 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/src/FormCollection/Form/UXCollectionType.php b/src/FormCollection/Form/UXCollectionType.php index 02ff3c0c514..d3c2d2e87e0 100644 --- a/src/FormCollection/Form/UXCollectionType.php +++ b/src/FormCollection/Form/UXCollectionType.php @@ -32,12 +32,12 @@ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'button_add' => [ - 'text' => 'Add', - 'attr' => ['class' => 'btn btn-outline-primary'], + 'text' => '', + 'class' => '', ], 'button_delete' => [ - 'text' => 'Remove', - 'attr' => ['class' => 'btn btn-outline-secondary'], + 'text' => '', + 'class' => '', ], ]); } @@ -53,6 +53,6 @@ public function finishView(FormView $view, FormInterface $form, array $options) public function getBlockPrefix() { - return 'form_collection'; + return 'ux_collection'; } } diff --git a/src/FormCollection/README.md b/src/FormCollection/README.md index f7c1b58a12a..77d48699750 100644 --- a/src/FormCollection/README.md +++ b/src/FormCollection/README.md @@ -53,14 +53,14 @@ class BlogFormType extends AbstractType 'button_add' => [ // Default text for the add button 'text' => 'Add', - // Default attr class for the add button - 'attr' => ['class' => 'btn btn-outline-primary'] + // Add HTML classes to the add button + 'class' => 'btn btn-outline-primary' ], 'button_delete' => [ // Default text for the delete button 'text' => 'Remove', - // Default class for the delete button - 'attr' => ['class' => 'btn btn-outline-secondary'] + // Add HTML classes to the add button + 'class' => 'btn btn-outline-secondary' ], ]) // ... @@ -71,6 +71,18 @@ class BlogFormType extends AbstractType } ``` +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: diff --git a/src/FormCollection/Resources/views/form_theme_div.html.twig b/src/FormCollection/Resources/views/form_theme_div.html.twig index 47f2a3c5b24..eae309cc496 100644 --- a/src/FormCollection/Resources/views/form_theme_div.html.twig +++ b/src/FormCollection/Resources/views/form_theme_div.html.twig @@ -1,17 +1,17 @@ {%- block button_add -%} {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#add')|trim -%} + class="{{ button_add.class|default('') }}" type="button">{{ button_add.text|default('Add')|trans }} {%- endblock button_add -%} {%- block button_delete -%} {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#delete')|trim -%} + class="{{ button_delete.class|default('') }}" type="button"> + {{ button_delete.text|default('Remove')|trans }} {%- endblock button_delete -%} -{% block form_collection_widget -%} +{% block ux_collection_widget -%} {%- set controllerName = 'symfony--ux-form-collection--collection' -%} {%- set dataController = (attr['data-controller']|default('') ~ ' ' ~ controllerName)|trim -%} @@ -19,11 +19,11 @@ {%- set attrDataTarget = {('data-' ~ controllerName ~ '-target'): 'entry' } -%}
{% if prototype is defined and not prototype.rendered %} {%- set prototype_attr = prototype.vars.attr|merge(attrDataTarget) -%} diff --git a/src/FormCollection/Resources/views/form_theme_table.html.twig b/src/FormCollection/Resources/views/form_theme_table.html.twig index 75c5745b1ab..2ca17aadeda 100644 --- a/src/FormCollection/Resources/views/form_theme_table.html.twig +++ b/src/FormCollection/Resources/views/form_theme_table.html.twig @@ -3,7 +3,7 @@ + class="{{ button_add.class|default('') }}" type="button">{{ button_add.text|default('Add')|trans }} {%- endblock button_add -%} @@ -11,11 +11,11 @@ {%- block button_delete -%} {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#delete')|trim -%} + class="{{ button_delete.class|default('') }}" type="button"> + {{ button_delete.text|default('Remove')|trans }} {%- endblock button_delete -%} -{% block form_collection_widget -%} +{% block ux_collection_widget -%} {%- set controllerName = 'symfony--ux-form-collection--collection' -%} {%- set dataController = (attr['data-controller']|default('') ~ ' ' ~ controllerName)|trim -%} @@ -23,10 +23,11 @@ {%- set attrDataTarget = {('data-' ~ controllerName ~ '-target'): 'entry' } -%}
{% if prototype is defined and not prototype.rendered %} {%- set prototype_attr = prototype.vars.attr|merge(attrDataTarget) -%} From 66defabee2b376faadecfed81520fc701b4c210e Mon Sep 17 00:00:00 2001 From: Albin Date: Sun, 23 May 2021 15:13:12 +0200 Subject: [PATCH 10/34] Predefined theme or not --- src/FormCollection/README.md | 46 +++++++++++++-- .../Resources/assets/dist/controller.js | 54 +++++++++-------- .../Resources/assets/src/controller.js | 59 ++++++++++--------- .../Resources/views/form_theme_div.html.twig | 28 ++++----- .../views/form_theme_table.html.twig | 14 ++--- 5 files changed, 124 insertions(+), 77 deletions(-) diff --git a/src/FormCollection/README.md b/src/FormCollection/README.md index 77d48699750..224bc8cc7f7 100644 --- a/src/FormCollection/README.md +++ b/src/FormCollection/README.md @@ -20,6 +20,8 @@ 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 @@ -33,6 +35,42 @@ You have 2 different themes : [Check the Symfony doc](https://symfony.com/doc/4.4/form/form_themes.html) for the different ways to set themes in Symfony. +## Use manual theming + +> 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 @@ -51,15 +89,15 @@ class BlogFormType extends AbstractType ->add('comments', UXCollectionType::class, [ // ... 'button_add' => [ - // Default text for the add button + // Default text for the add button (used by predefined theme) 'text' => 'Add', - // Add HTML classes to the add button + // Add HTML classes to the add button (used by predefined theme) 'class' => 'btn btn-outline-primary' ], 'button_delete' => [ - // Default text for the delete button + // Default text for the delete button (used by predefined theme) 'text' => 'Remove', - // Add HTML classes to the add button + // Add HTML classes to the add button (used by predefined theme) 'class' => 'btn btn-outline-secondary' ], ]) diff --git a/src/FormCollection/Resources/assets/dist/controller.js b/src/FormCollection/Resources/assets/dist/controller.js index 271c7ca00e2..15236f786d0 100644 --- a/src/FormCollection/Resources/assets/dist/controller.js +++ b/src/FormCollection/Resources/assets/dist/controller.js @@ -58,6 +58,11 @@ var _default = /*#__PURE__*/function (_Controller) { key: "connect", value: function connect() { this.controllerName = this.context.scope.identifier; + this.index = this.entryTargets.length - 1; + + if (!this.prototypeNameValue) { + this.prototypeNameValue = '__name__'; + } this._dispatchEvent('form-collection:pre-connect', { allowAdd: this.allowAddValue, @@ -68,16 +73,15 @@ var _default = /*#__PURE__*/function (_Controller) { // Add button Add var buttonAdd = this._textToNode(this.buttonAddValue); - this.containerTarget.prepend(buttonAdd); + this.element.prepend(buttonAdd); } // Add buttons Delete if (true === this.allowDeleteValue) { for (var i = 0; i < this.entryTargets.length; i++) { - this.index = i; var entry = this.entryTargets[i]; - this._addDeleteButton(entry, this.index); + this._addDeleteButton(entry, i); } } @@ -91,7 +95,12 @@ var _default = /*#__PURE__*/function (_Controller) { value: function add(event) { this.index++; // Compute the new entry - var newEntry = this.containerTarget.dataset.prototype; + var newEntry = this.element.dataset.prototype; + + if (!newEntry) { + newEntry = this.prototypeValue; + } + var regExp = new RegExp(this.prototypeNameValue + 'label__', 'g'); newEntry = newEntry.replace(regExp, this.index); regExp = new RegExp(this.prototypeNameValue, 'g'); @@ -103,7 +112,7 @@ var _default = /*#__PURE__*/function (_Controller) { element: newEntry }); - this.containerTarget.append(newEntry); // Retrieve the entry from targets to make sure that this is the one + this.element.append(newEntry); // Retrieve the entry from targets to make sure that this is the one var entry = this.entryTargets[this.entryTargets.length - 1]; entry = this._addDeleteButton(entry, this.index); @@ -116,25 +125,19 @@ var _default = /*#__PURE__*/function (_Controller) { }, { key: "delete", value: function _delete(event) { - var theIndexEntryToDelete = event.target.dataset.indexEntry; // Search the entry to delete from the data-index-entry attribute - - for (var i = 0; i < this.entryTargets.length; i++) { - var entry = this.entryTargets[i]; + var entry = event.target.closest('[data-' + this.controllerName + '-target="entry"]'); - if (theIndexEntryToDelete === entry.dataset.indexEntry) { - this._dispatchEvent('form-collection:pre-delete', { - index: entry.dataset.indexEntry, - element: entry - }); + this._dispatchEvent('form-collection:pre-delete', { + index: entry.dataset.indexEntry, + element: entry + }); - entry.remove(); + entry.remove(); - this._dispatchEvent('form-collection:delete', { - index: entry.dataset.indexEntry, - element: entry - }); - } - } + this._dispatchEvent('form-collection:delete', { + index: entry.dataset.indexEntry, + element: entry + }); } /** * Add the delete button to the entry @@ -152,6 +155,10 @@ var _default = /*#__PURE__*/function (_Controller) { var buttonDelete = this._textToNode(this.buttonDeleteValue); + if (!buttonDelete) { + return entry; + } + buttonDelete.dataset.indexEntry = index; if ('TR' === entry.nodeName) { @@ -195,12 +202,13 @@ var _default = /*#__PURE__*/function (_Controller) { exports["default"] = _default; -_defineProperty(_default, "targets", ['container', 'entry']); +_defineProperty(_default, "targets", ['entry']); _defineProperty(_default, "values", { allowAdd: Boolean, allowDelete: Boolean, buttonAdd: String, buttonDelete: String, - prototypeName: String + prototypeName: String, + prototype: String }); \ No newline at end of file diff --git a/src/FormCollection/Resources/assets/src/controller.js b/src/FormCollection/Resources/assets/src/controller.js index 72311e4ef4e..98a18addada 100644 --- a/src/FormCollection/Resources/assets/src/controller.js +++ b/src/FormCollection/Resources/assets/src/controller.js @@ -1,10 +1,9 @@ 'use strict'; -import { Controller } from 'stimulus'; +import {Controller} from 'stimulus'; export default class extends Controller { static targets = [ - 'container', 'entry' ]; @@ -13,7 +12,8 @@ export default class extends Controller { allowDelete: Boolean, buttonAdd: String, buttonDelete: String, - prototypeName: String + prototypeName: String, + prototype: String }; /** @@ -30,7 +30,11 @@ export default class extends Controller { connect() { this.controllerName = this.context.scope.identifier; + this.index = this.entryTargets.length - 1; + if (!this.prototypeNameValue) { + this.prototypeNameValue = '__name__'; + } this._dispatchEvent('form-collection:pre-connect', { allowAdd: this.allowAddValue, @@ -40,15 +44,14 @@ export default class extends Controller { if (true === this.allowAddValue) { // Add button Add let buttonAdd = this._textToNode(this.buttonAddValue); - this.containerTarget.prepend(buttonAdd); + this.element.prepend(buttonAdd); } // Add buttons Delete if (true === this.allowDeleteValue) { for (let i = 0; i < this.entryTargets.length; i++) { - this.index = i; let entry = this.entryTargets[i]; - this._addDeleteButton(entry, this.index); + this._addDeleteButton(entry, i); } } @@ -63,9 +66,12 @@ export default class extends Controller { this.index++; // Compute the new entry - let newEntry = this.containerTarget.dataset.prototype; - - let regExp = new RegExp(this.prototypeNameValue+'label__', 'g'); + 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'); @@ -78,7 +84,7 @@ export default class extends Controller { element: newEntry }); - this.containerTarget.append(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]; @@ -91,27 +97,19 @@ export default class extends Controller { } delete(event) { + let entry = event.target.closest('[data-' + this.controllerName + '-target="entry"]'); - let theIndexEntryToDelete = event.target.dataset.indexEntry; - - // Search the entry to delete from the data-index-entry attribute - for (let i = 0; i < this.entryTargets.length; i++) { - let entry = this.entryTargets[i]; - if (theIndexEntryToDelete === entry.dataset.indexEntry) { - - this._dispatchEvent('form-collection:pre-delete', { - index: entry.dataset.indexEntry, - element: entry - }); + this._dispatchEvent('form-collection:pre-delete', { + index: entry.dataset.indexEntry, + element: entry + }); - entry.remove(); + entry.remove(); - this._dispatchEvent('form-collection:delete', { - index: entry.dataset.indexEntry, - element: entry - }); - } - } + this._dispatchEvent('form-collection:delete', { + index: entry.dataset.indexEntry, + element: entry + }); } /** @@ -127,9 +125,12 @@ export default class extends Controller { entry.dataset.indexEntry = index; let buttonDelete = this._textToNode(this.buttonDeleteValue); + if (!buttonDelete) { + return entry; + } buttonDelete.dataset.indexEntry = index; - if('TR' === entry.nodeName) { + if ('TR' === entry.nodeName) { entry.lastElementChild.append(buttonDelete); } else { entry.append(buttonDelete); diff --git a/src/FormCollection/Resources/views/form_theme_div.html.twig b/src/FormCollection/Resources/views/form_theme_div.html.twig index eae309cc496..dff35ae93f3 100644 --- a/src/FormCollection/Resources/views/form_theme_div.html.twig +++ b/src/FormCollection/Resources/views/form_theme_div.html.twig @@ -18,31 +18,31 @@ {# 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.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' }) -%} +
- {% if prototype is defined and not prototype.rendered %} - {%- set prototype_attr = prototype.vars.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' }) -%} + {%- if form is rootform -%} + {{ form_errors(form) }} + {%- endif -%} -
- {%- if form is rootform -%} - {{ form_errors(form) }} - {%- endif -%} + {% for child in form|filter(child => not child.rendered) %} - {% for child in form|filter(child => not child.rendered) %} + {%- set child_attr = child.vars.attr|merge(attrDataTarget) -%} + {{- form_row(child, {'row_attr': child_attr}) -}} - {%- set child_attr = child.vars.attr|merge(attrDataTarget) -%} - {{- form_row(child, {'row_attr': child_attr}) -}} + {% endfor %} - {% 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 index 2ca17aadeda..0bf6f012507 100644 --- a/src/FormCollection/Resources/views/form_theme_table.html.twig +++ b/src/FormCollection/Resources/views/form_theme_table.html.twig @@ -22,20 +22,20 @@ {# 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.attr|merge(attrDataTarget) -%} + {%- set attr = attr|merge({'data-prototype': form_row(prototype, {'row_attr': prototype_attr}) }) -%} + {% endif %} +
- {% if prototype is defined and not prototype.rendered %} - {%- set prototype_attr = prototype.vars.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' }) -%} - - +
{%- if form is rootform -%} {{ form_errors(form) }} {%- endif -%} From 145b1cb2089b87bfc8e1ccbcaea6aabca88e3fe1 Mon Sep 17 00:00:00 2001 From: Stakovicz <83301974+stakovicz@users.noreply.github.com> Date: Mon, 24 May 2021 14:12:02 +0200 Subject: [PATCH 11/34] Update src/FormCollection/README.md Co-authored-by: jmsche --- src/FormCollection/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/FormCollection/README.md b/src/FormCollection/README.md index 224bc8cc7f7..24b23caa0cf 100644 --- a/src/FormCollection/README.md +++ b/src/FormCollection/README.md @@ -22,7 +22,8 @@ in your `package.json` file. ## Use predefined theme -You need to select the right theme from the one you are using : +You need to select the right theme from the one you are using: + ```yaml # config/packages/twig.yaml twig: @@ -188,4 +189,4 @@ Then in your render call, add your controller as an HTML attribute: 'data-controller' => 'mycollection' ] ]); -``` \ No newline at end of file +``` From 2b900cc45f10160b8d6c810c8855b481008910a1 Mon Sep 17 00:00:00 2001 From: Stakovicz <83301974+stakovicz@users.noreply.github.com> Date: Mon, 24 May 2021 14:12:10 +0200 Subject: [PATCH 12/34] Update src/FormCollection/README.md Co-authored-by: jmsche --- src/FormCollection/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/FormCollection/README.md b/src/FormCollection/README.md index 24b23caa0cf..82ebc7d9ae1 100644 --- a/src/FormCollection/README.md +++ b/src/FormCollection/README.md @@ -30,7 +30,9 @@ twig: # For bootstrap for example form_themes: ['@FormCollection/form_theme_div.html.twig'] ``` -You have 2 different themes : + +There are 2 predefined themes available: + - `@FormCollection/form_theme_div.html.twig` - `@FormCollection/form_theme_table.html.twig` From 25c1454a725e8b5fa0e236581a9c329c02aa9b4a Mon Sep 17 00:00:00 2001 From: Stakovicz <83301974+stakovicz@users.noreply.github.com> Date: Mon, 24 May 2021 14:12:19 +0200 Subject: [PATCH 13/34] Update src/FormCollection/README.md Co-authored-by: jmsche --- src/FormCollection/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FormCollection/README.md b/src/FormCollection/README.md index 82ebc7d9ae1..ad71edb50d6 100644 --- a/src/FormCollection/README.md +++ b/src/FormCollection/README.md @@ -40,7 +40,7 @@ There are 2 predefined themes available: ## Use manual theming -> Consider your `BlogFormType` form set up and with a comments field that is a `CollectionType`, you can +Consider your `BlogFormType` form set up and with a comments field that is a `CollectionType`, you can render it in your template: ```twig From d66250d00d0da845893929e802c25ae7fff0630d Mon Sep 17 00:00:00 2001 From: Stakovicz <83301974+stakovicz@users.noreply.github.com> Date: Mon, 24 May 2021 14:12:50 +0200 Subject: [PATCH 14/34] Update src/FormCollection/Resources/views/form_theme_div.html.twig Co-authored-by: jmsche --- src/FormCollection/Resources/views/form_theme_div.html.twig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FormCollection/Resources/views/form_theme_div.html.twig b/src/FormCollection/Resources/views/form_theme_div.html.twig index dff35ae93f3..417604b2c87 100644 --- a/src/FormCollection/Resources/views/form_theme_div.html.twig +++ b/src/FormCollection/Resources/views/form_theme_div.html.twig @@ -27,8 +27,8 @@
From 2db95e071b8b4f4fbdcd79004bb23633126d00c9 Mon Sep 17 00:00:00 2001 From: Stakovicz <83301974+stakovicz@users.noreply.github.com> Date: Mon, 24 May 2021 14:14:53 +0200 Subject: [PATCH 15/34] Update src/FormCollection/README.md Co-authored-by: jmsche --- src/FormCollection/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FormCollection/README.md b/src/FormCollection/README.md index ad71edb50d6..a8e46bb827b 100644 --- a/src/FormCollection/README.md +++ b/src/FormCollection/README.md @@ -38,7 +38,7 @@ There are 2 predefined themes available: [Check the Symfony doc](https://symfony.com/doc/4.4/form/form_themes.html) for the different ways to set themes in Symfony. -## Use manual theming +## 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: From 8f8b5c29a1cc3b1f1fb2730111bbb97f2aa13ff4 Mon Sep 17 00:00:00 2001 From: Stakovicz <83301974+stakovicz@users.noreply.github.com> Date: Mon, 24 May 2021 14:15:14 +0200 Subject: [PATCH 16/34] Update src/FormCollection/Resources/views/form_theme_table.html.twig Co-authored-by: jmsche --- src/FormCollection/Resources/views/form_theme_table.html.twig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FormCollection/Resources/views/form_theme_table.html.twig b/src/FormCollection/Resources/views/form_theme_table.html.twig index 0bf6f012507..64e4bb3cec8 100644 --- a/src/FormCollection/Resources/views/form_theme_table.html.twig +++ b/src/FormCollection/Resources/views/form_theme_table.html.twig @@ -30,8 +30,8 @@
From 4a68faae5b32a67d5a928ec4cd4ba63eb173dfe6 Mon Sep 17 00:00:00 2001 From: Albin Date: Mon, 24 May 2021 14:45:01 +0200 Subject: [PATCH 17/34] Split in 4 options --- src/FormCollection/Form/UXCollectionType.php | 23 +++++++++++-------- src/FormCollection/README.md | 20 +++++++--------- .../Resources/views/form_theme_div.html.twig | 6 ++--- .../views/form_theme_table.html.twig | 6 ++--- 4 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/FormCollection/Form/UXCollectionType.php b/src/FormCollection/Form/UXCollectionType.php index d3c2d2e87e0..095807cb0e5 100644 --- a/src/FormCollection/Form/UXCollectionType.php +++ b/src/FormCollection/Form/UXCollectionType.php @@ -31,23 +31,26 @@ public function getParent() public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ - 'button_add' => [ - 'text' => '', - 'class' => '', - ], - 'button_delete' => [ - 'text' => '', - 'class' => '', - ], + 'button_add_text' => 'Add', + 'button_add_class' => '', + 'button_delete_text' => 'Remove', + 'button_delete_class' => '', ]); + + $resolver->setAllowedTypes('button_add_text', 'string'); + $resolver->setAllowedTypes('button_add_class', 'string'); + $resolver->setAllowedTypes('button_delete_text', 'string'); + $resolver->setAllowedTypes('button_delete_class', 'string'); } public function finishView(FormView $view, FormInterface $form, array $options) { parent::finishView($view, $form, $options); - $view->vars['button_add'] = $options['button_add']; - $view->vars['button_delete'] = $options['button_delete']; + $view->vars['button_add_text'] = $options['button_add_text']; + $view->vars['button_add_class'] = $options['button_add_class']; + $view->vars['button_delete_text'] = $options['button_delete_text']; + $view->vars['button_delete_class'] = $options['button_delete_class']; $view->vars['prototype_name'] = $options['prototype_name']; } diff --git a/src/FormCollection/README.md b/src/FormCollection/README.md index a8e46bb827b..de3a6a405aa 100644 --- a/src/FormCollection/README.md +++ b/src/FormCollection/README.md @@ -91,18 +91,14 @@ class BlogFormType extends AbstractType // ... ->add('comments', UXCollectionType::class, [ // ... - 'button_add' => [ - // Default text for the add button (used by predefined theme) - 'text' => 'Add', - // Add HTML classes to the add button (used by predefined theme) - 'class' => 'btn btn-outline-primary' - ], - 'button_delete' => [ - // Default text for the delete button (used by predefined theme) - 'text' => 'Remove', - // Add HTML classes to the add button (used by predefined theme) - 'class' => 'btn btn-outline-secondary' - ], + // Default text for the add button (used by predefined theme) + 'button_add_text' => 'Add', + // Add HTML classes to the add button (used by predefined theme) + 'button_add_class' => 'btn btn-outline-primary', + // Default text for the delete button (used by predefined theme) + 'button_delete_text' => 'Remove', + // Add HTML classes to the add button (used by predefined theme) + 'button_delete_class' => 'btn btn-outline-secondary', ]) // ... ; diff --git a/src/FormCollection/Resources/views/form_theme_div.html.twig b/src/FormCollection/Resources/views/form_theme_div.html.twig index 417604b2c87..fb2aabcb48e 100644 --- a/src/FormCollection/Resources/views/form_theme_div.html.twig +++ b/src/FormCollection/Resources/views/form_theme_div.html.twig @@ -1,14 +1,14 @@ {%- block button_add -%} {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#add')|trim -%} + class="{{ button_add_class }}" type="button">{{ button_add_text|trans }} {%- endblock button_add -%} {%- block button_delete -%} {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#delete')|trim -%} + class="{{ button_delete_class }}" type="button"> + {{ button_delete_text|trans }} {%- endblock button_delete -%} {% block ux_collection_widget -%} diff --git a/src/FormCollection/Resources/views/form_theme_table.html.twig b/src/FormCollection/Resources/views/form_theme_table.html.twig index 64e4bb3cec8..4522d831a4d 100644 --- a/src/FormCollection/Resources/views/form_theme_table.html.twig +++ b/src/FormCollection/Resources/views/form_theme_table.html.twig @@ -3,7 +3,7 @@
{%- endblock button_add -%} @@ -11,8 +11,8 @@ {%- block button_delete -%} {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#delete')|trim -%} + class="{{ button_delete_class }}" type="button"> + {{ button_delete_text|trans }} {%- endblock button_delete -%} {% block ux_collection_widget -%} From a170618aec95f5b5e7007182d8d05fdfd2871627 Mon Sep 17 00:00:00 2001 From: Albin Date: Sun, 6 Jun 2021 13:54:44 +0200 Subject: [PATCH 18/34] Default startIndex value --- src/FormCollection/Resources/assets/dist/controller.js | 5 +++-- src/FormCollection/Resources/assets/src/controller.js | 5 +++-- src/FormCollection/Resources/views/form_theme_div.html.twig | 4 ++++ .../Resources/views/form_theme_table.html.twig | 4 ++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/FormCollection/Resources/assets/dist/controller.js b/src/FormCollection/Resources/assets/dist/controller.js index 15236f786d0..e3aced57ace 100644 --- a/src/FormCollection/Resources/assets/dist/controller.js +++ b/src/FormCollection/Resources/assets/dist/controller.js @@ -58,7 +58,7 @@ var _default = /*#__PURE__*/function (_Controller) { key: "connect", value: function connect() { this.controllerName = this.context.scope.identifier; - this.index = this.entryTargets.length - 1; + this.index = this.startIndexValue ? this.startIndexValue : this.entryTargets.length - 1; if (!this.prototypeNameValue) { this.prototypeNameValue = '__name__'; @@ -210,5 +210,6 @@ _defineProperty(_default, "values", { buttonAdd: String, buttonDelete: String, prototypeName: String, - prototype: String + prototype: String, + startIndex: Number }); \ No newline at end of file diff --git a/src/FormCollection/Resources/assets/src/controller.js b/src/FormCollection/Resources/assets/src/controller.js index 98a18addada..b8547947220 100644 --- a/src/FormCollection/Resources/assets/src/controller.js +++ b/src/FormCollection/Resources/assets/src/controller.js @@ -13,7 +13,8 @@ export default class extends Controller { buttonAdd: String, buttonDelete: String, prototypeName: String, - prototype: String + prototype: String, + startIndex: Number }; /** @@ -30,7 +31,7 @@ export default class extends Controller { connect() { this.controllerName = this.context.scope.identifier; - this.index = this.entryTargets.length - 1; + this.index = this.startIndexValue ? this.startIndexValue : this.entryTargets.length - 1; if (!this.prototypeNameValue) { this.prototypeNameValue = '__name__'; diff --git a/src/FormCollection/Resources/views/form_theme_div.html.twig b/src/FormCollection/Resources/views/form_theme_div.html.twig index fb2aabcb48e..cf1e9f8326b 100644 --- a/src/FormCollection/Resources/views/form_theme_div.html.twig +++ b/src/FormCollection/Resources/views/form_theme_div.html.twig @@ -24,12 +24,16 @@ {% 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 -%} diff --git a/src/FormCollection/Resources/views/form_theme_table.html.twig b/src/FormCollection/Resources/views/form_theme_table.html.twig index 4522d831a4d..5692477a281 100644 --- a/src/FormCollection/Resources/views/form_theme_table.html.twig +++ b/src/FormCollection/Resources/views/form_theme_table.html.twig @@ -27,12 +27,16 @@ {%- 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) %} +
+ class="{{ button_add_class }}" type="button">{{ button_add_text|trans }}
From 13561e7841e66a07d01e1267addade60268fbe2b Mon Sep 17 00:00:00 2001 From: Stakovicz <83301974+stakovicz@users.noreply.github.com> Date: Wed, 21 Jul 2021 08:30:07 +0200 Subject: [PATCH 19/34] Update src/FormCollection/Resources/views/form_theme_div.html.twig Co-authored-by: Romain Monteil --- src/FormCollection/Resources/views/form_theme_div.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FormCollection/Resources/views/form_theme_div.html.twig b/src/FormCollection/Resources/views/form_theme_div.html.twig index cf1e9f8326b..21ed12e5d73 100644 --- a/src/FormCollection/Resources/views/form_theme_div.html.twig +++ b/src/FormCollection/Resources/views/form_theme_div.html.twig @@ -19,7 +19,7 @@ {%- set attrDataTarget = {('data-' ~ controllerName ~ '-target'): 'entry' } -%} {% if prototype is defined and not prototype.rendered %} - {%- set prototype_attr = prototype.vars.attr|merge(attrDataTarget) -%} + {%- 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' }) -%} From fb847c6fc77f49c7f7e8c0f1a7eb3a396fda501e Mon Sep 17 00:00:00 2001 From: Stakovicz <83301974+stakovicz@users.noreply.github.com> Date: Wed, 21 Jul 2021 08:30:15 +0200 Subject: [PATCH 20/34] Update src/FormCollection/Resources/views/form_theme_div.html.twig Co-authored-by: Romain Monteil --- src/FormCollection/Resources/views/form_theme_div.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FormCollection/Resources/views/form_theme_div.html.twig b/src/FormCollection/Resources/views/form_theme_div.html.twig index 21ed12e5d73..3380930e574 100644 --- a/src/FormCollection/Resources/views/form_theme_div.html.twig +++ b/src/FormCollection/Resources/views/form_theme_div.html.twig @@ -42,7 +42,7 @@ {% for child in form|filter(child => not child.rendered) %} - {%- set child_attr = child.vars.attr|merge(attrDataTarget) -%} + {%- set child_attr = child.vars.row_attr|merge(attrDataTarget) -%} {{- form_row(child, {'row_attr': child_attr}) -}} {% endfor %} From 740d19bcb9c73ca3a665cd995d14ee858c67a740 Mon Sep 17 00:00:00 2001 From: Stakovicz <83301974+stakovicz@users.noreply.github.com> Date: Wed, 21 Jul 2021 08:30:22 +0200 Subject: [PATCH 21/34] Update src/FormCollection/Resources/views/form_theme_table.html.twig Co-authored-by: Romain Monteil --- src/FormCollection/Resources/views/form_theme_table.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FormCollection/Resources/views/form_theme_table.html.twig b/src/FormCollection/Resources/views/form_theme_table.html.twig index 5692477a281..ff2a9a46b4c 100644 --- a/src/FormCollection/Resources/views/form_theme_table.html.twig +++ b/src/FormCollection/Resources/views/form_theme_table.html.twig @@ -23,7 +23,7 @@ {%- set attrDataTarget = {('data-' ~ controllerName ~ '-target'): 'entry' } -%} {% if prototype is defined and not prototype.rendered %} - {%- set prototype_attr = prototype.vars.attr|merge(attrDataTarget) -%} + {%- set prototype_attr = prototype.vars.row_attr|merge(attrDataTarget) -%} {%- set attr = attr|merge({'data-prototype': form_row(prototype, {'row_attr': prototype_attr}) }) -%} {% endif %} From c57d86ede02497de20543caa255c1dcda427cd6b Mon Sep 17 00:00:00 2001 From: Stakovicz <83301974+stakovicz@users.noreply.github.com> Date: Wed, 21 Jul 2021 08:30:38 +0200 Subject: [PATCH 22/34] Update src/FormCollection/Resources/views/form_theme_table.html.twig Co-authored-by: Romain Monteil --- src/FormCollection/Resources/views/form_theme_table.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FormCollection/Resources/views/form_theme_table.html.twig b/src/FormCollection/Resources/views/form_theme_table.html.twig index ff2a9a46b4c..77af4be8510 100644 --- a/src/FormCollection/Resources/views/form_theme_table.html.twig +++ b/src/FormCollection/Resources/views/form_theme_table.html.twig @@ -46,7 +46,7 @@ {% for child in form|filter(child => not child.rendered) %} - {%- set child_attr = child.vars.attr|merge(attrDataTarget) -%} + {%- set child_attr = child.vars.row_attr|merge(attrDataTarget) -%} {{- form_row(child, {'row_attr': child_attr}) -}} {% endfor %} From 0bbac2f4ff92ac46535bb669578dbf9f33ddbfa3 Mon Sep 17 00:00:00 2001 From: akester Date: Sat, 6 Nov 2021 21:04:18 +0100 Subject: [PATCH 23/34] Fix coding-style-js --- src/FormCollection/Resources/assets/src/controller.js | 2 +- src/FormCollection/Resources/assets/test/controller.test.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/FormCollection/Resources/assets/src/controller.js b/src/FormCollection/Resources/assets/src/controller.js index b8547947220..d1341691675 100644 --- a/src/FormCollection/Resources/assets/src/controller.js +++ b/src/FormCollection/Resources/assets/src/controller.js @@ -62,7 +62,7 @@ export default class extends Controller { }); } - add(event) { + add() { this.index++; diff --git a/src/FormCollection/Resources/assets/test/controller.test.js b/src/FormCollection/Resources/assets/test/controller.test.js index f764a48d6e8..e22620cfc25 100644 --- a/src/FormCollection/Resources/assets/test/controller.test.js +++ b/src/FormCollection/Resources/assets/test/controller.test.js @@ -11,7 +11,6 @@ import { Application, Controller } from 'stimulus'; import { getByTestId, waitFor } from '@testing-library/dom'; -import user from '@testing-library/user-event'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; import FormCollectionController from '../dist/controller'; From edfe323c6b0a97a957405d5a1e1a88a13f43774d Mon Sep 17 00:00:00 2001 From: akester Date: Sat, 6 Nov 2021 21:09:41 +0100 Subject: [PATCH 24/34] Prettier --- src/FormCollection/README.md | 22 +++++++++---------- .../Resources/assets/src/controller.js | 19 ++++++---------- .../Resources/assets/test/controller.test.js | 1 - 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/src/FormCollection/README.md b/src/FormCollection/README.md index de3a6a405aa..8de796c67cd 100644 --- a/src/FormCollection/README.md +++ b/src/FormCollection/README.md @@ -27,14 +27,14 @@ 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'] + # 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` +- `@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. @@ -92,11 +92,11 @@ class BlogFormType extends AbstractType ->add('comments', UXCollectionType::class, [ // ... // Default text for the add button (used by predefined theme) - 'button_add_text' => 'Add', + 'button_add_text' => 'Add', // Add HTML classes to the add button (used by predefined theme) 'button_add_class' => 'btn btn-outline-primary', // Default text for the delete button (used by predefined theme) - 'button_delete_text' => 'Remove', + 'button_delete_text' => 'Remove', // Add HTML classes to the add button (used by predefined theme) 'button_delete_class' => 'btn btn-outline-secondary', ]) @@ -147,7 +147,7 @@ export default class extends Controller { _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.allowAdd); // Access to the allow_add option of the form console.log(event.detail.allowDelete); // Access to the allow_delete option of the form } @@ -156,7 +156,7 @@ export default class extends Controller { } _onPreAdd(event) { - console.log(event.detail.index); // Access to the curent index will be added + 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 } @@ -165,7 +165,7 @@ export default class extends Controller { } _onPreDelete(event) { - console.log(event.detail.index); // Access to the index will be removed + console.log(event.detail.index); // Access to the index will be removed console.log(event.detail.element); // Access to the elemnt will be removed } @@ -183,8 +183,8 @@ Then in your render call, add your controller as an HTML attribute: ->add('comments', UXCollectionType::class, [ // ... 'attr' => [ - // Change the controller name - 'data-controller' => 'mycollection' + // Change the controller name + 'data-controller' => 'mycollection' ] ]); ``` diff --git a/src/FormCollection/Resources/assets/src/controller.js b/src/FormCollection/Resources/assets/src/controller.js index d1341691675..6aae58379e5 100644 --- a/src/FormCollection/Resources/assets/src/controller.js +++ b/src/FormCollection/Resources/assets/src/controller.js @@ -1,11 +1,9 @@ 'use strict'; -import {Controller} from 'stimulus'; +import { Controller } from 'stimulus'; export default class extends Controller { - static targets = [ - 'entry' - ]; + static targets = ['entry']; static values = { allowAdd: Boolean, @@ -14,7 +12,7 @@ export default class extends Controller { buttonDelete: String, prototypeName: String, prototype: String, - startIndex: Number + startIndex: Number, }; /** @@ -63,7 +61,6 @@ export default class extends Controller { } add() { - this.index++; // Compute the new entry @@ -82,7 +79,7 @@ export default class extends Controller { this._dispatchEvent('form-collection:pre-add', { index: this.index, - element: newEntry + element: newEntry, }); this.element.append(newEntry); @@ -93,7 +90,7 @@ export default class extends Controller { this._dispatchEvent('form-collection:add', { index: this.index, - element: entry + element: entry, }); } @@ -102,14 +99,14 @@ export default class extends Controller { this._dispatchEvent('form-collection:pre-delete', { index: entry.dataset.indexEntry, - element: entry + element: entry, }); entry.remove(); this._dispatchEvent('form-collection:delete', { index: entry.dataset.indexEntry, - element: entry + element: entry, }); } @@ -121,7 +118,6 @@ export default class extends Controller { * @private */ _addDeleteButton(entry, index) { - // link the button and the entry by the data-index-entry attribute entry.dataset.indexEntry = index; @@ -147,7 +143,6 @@ export default class extends Controller { * @private */ _textToNode(text) { - let template = document.createElement('template'); text = text.trim(); // Never return a text node of whitespace as the result template.innerHTML = text; diff --git a/src/FormCollection/Resources/assets/test/controller.test.js b/src/FormCollection/Resources/assets/test/controller.test.js index e22620cfc25..ea9b89903df 100644 --- a/src/FormCollection/Resources/assets/test/controller.test.js +++ b/src/FormCollection/Resources/assets/test/controller.test.js @@ -55,5 +55,4 @@ describe('FormCollectionController', () => { await waitFor(() => expect(getByTestId(container, 'container')).toHaveClass('connected')); await waitFor(() => expect(getByTestId(container, 'container')).toHaveClass('pre-connected')); }); - }); From 086c3a303c77698e068d1acb1c656e6886f15186 Mon Sep 17 00:00:00 2001 From: akester Date: Sat, 21 May 2022 21:43:57 +0200 Subject: [PATCH 25/34] Rebase and refresh the code --- .../Resources/assets/dist/controller.js | 300 ++++++------------ .../Resources/assets/jest.config.js | 5 + .../Resources/assets/package.json | 20 +- .../src/{controller.js => controller.ts} | 51 ++- ...{controller.test.js => controller.test.ts} | 4 +- 5 files changed, 128 insertions(+), 252 deletions(-) create mode 100644 src/FormCollection/Resources/assets/jest.config.js rename src/FormCollection/Resources/assets/src/{controller.js => controller.ts} (75%) rename src/FormCollection/Resources/assets/test/{controller.test.js => controller.test.ts} (95%) diff --git a/src/FormCollection/Resources/assets/dist/controller.js b/src/FormCollection/Resources/assets/dist/controller.js index e3aced57ace..8e5ae980139 100644 --- a/src/FormCollection/Resources/assets/dist/controller.js +++ b/src/FormCollection/Resources/assets/dist/controller.js @@ -1,215 +1,105 @@ -'use strict'; +import { Controller } from '@hotwired/stimulus'; -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"); - -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 = /*#__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]; +class default_1 extends Controller { + constructor() { + super(...arguments); + this.index = 0; } - - _this = _super.call.apply(_super, [this].concat(args)); - - _defineProperty(_assertThisInitialized(_this), "index", 0); - - _defineProperty(_assertThisInitialized(_this), "controllerName", null); - - return _this; - } - - _createClass(_default, [{ - key: "connect", - value: function 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 (true === this.allowAddValue) { - // Add button Add - var buttonAdd = this._textToNode(this.buttonAddValue); - - this.element.prepend(buttonAdd); - } // Add buttons Delete - - - if (true === this.allowDeleteValue) { - for (var i = 0; i < this.entryTargets.length; i++) { - var entry = this.entryTargets[i]; - - this._addDeleteButton(entry, i); + 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:connect', { - allowAdd: this.allowAddValue, - allowDelete: this.allowDeleteValue - }); + 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, + }); } - }, { - key: "add", - value: function add(event) { - this.index++; // Compute the new entry - - var newEntry = this.element.dataset.prototype; - - if (!newEntry) { - newEntry = this.prototypeValue; - } - - var 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 - - var entry = this.entryTargets[this.entryTargets.length - 1]; - entry = this._addDeleteButton(entry, this.index); - - this._dispatchEvent('form-collection:add', { - index: this.index, - element: entry - }); + 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, + }); } - }, { - key: "delete", - value: function _delete(event) { - var 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 - }); + 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 - * @param String entry - * @param Number index - * @returns {ChildNode} - * @private - */ - - }, { - key: "_addDeleteButton", - value: function _addDeleteButton(entry, index) { - // link the button and the entry by the data-index-entry attribute - entry.dataset.indexEntry = index; - - var buttonDelete = this._textToNode(this.buttonDeleteValue); - - if (!buttonDelete) { + _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; - } - - 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 - * @param String text - * @returns {ChildNode} - * @private - */ - - }, { - key: "_textToNode", - value: function _textToNode(text) { - var template = document.createElement('template'); - text = text.trim(); // Never return a text node of whitespace as the result - - template.innerHTML = text; - return template.content.firstChild; + _textToNode(text) { + const template = document.createElement('template'); + text = text.trim(); + template.innerHTML = text; + return template.content.firstChild; } - }, { - 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] : false; - var cancelable = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; - var userEvent = document.createEvent('CustomEvent'); - userEvent.initCustomEvent(name, canBubble, cancelable, payload); - this.element.dispatchEvent(userEvent); + _dispatchEvent(name, payload) { + console.log('TTTT'); + this.element.dispatchEvent(new CustomEvent(name, { detail: payload })); } - }]); - - return _default; -}(_stimulus.Controller); - -exports["default"] = _default; - -_defineProperty(_default, "targets", ['entry']); - -_defineProperty(_default, "values", { - allowAdd: Boolean, - allowDelete: Boolean, - buttonAdd: String, - buttonDelete: String, - prototypeName: String, - prototype: String, - startIndex: Number -}); \ No newline at end of file +} +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 index f30194403c6..bccdc162439 100644 --- a/src/FormCollection/Resources/assets/package.json +++ b/src/FormCollection/Resources/assets/package.json @@ -13,26 +13,10 @@ } } }, - "scripts": { - "build": "babel src -d dist", - "test": "babel src -d dist && jest", - "lint": "eslint src test" - }, "peerDependencies": { - "stimulus": "^2.0.0" + "@hotwired/stimulus": "^3.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", - "stimulus": "^2.0.0" - }, - "jest": { - "testRegex": "test/.*\\.test.js", - "setupFilesAfterEnv": [ - "./test/setup.js" - ] + "@hotwired/stimulus": "^3.0.0" } } diff --git a/src/FormCollection/Resources/assets/src/controller.js b/src/FormCollection/Resources/assets/src/controller.ts similarity index 75% rename from src/FormCollection/Resources/assets/src/controller.js rename to src/FormCollection/Resources/assets/src/controller.ts index 6aae58379e5..fcc288fe3ab 100644 --- a/src/FormCollection/Resources/assets/src/controller.js +++ b/src/FormCollection/Resources/assets/src/controller.ts @@ -1,6 +1,6 @@ 'use strict'; -import { Controller } from 'stimulus'; +import { Controller } from '@hotwired/stimulus'; export default class extends Controller { static targets = ['entry']; @@ -15,17 +15,22 @@ export default class extends Controller { 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 - * @type Number */ index = 0; - /** - * Controller name of this - * @type String|null - */ - controllerName = null; + controllerName: string; + + entryTargets: Array; connect() { this.controllerName = this.context.scope.identifier; @@ -40,16 +45,16 @@ export default class extends Controller { allowDelete: this.allowDeleteValue, }); - if (true === this.allowAddValue) { + if (this.allowAddValue) { // Add button Add - let buttonAdd = this._textToNode(this.buttonAddValue); + const buttonAdd = this._textToNode(this.buttonAddValue); this.element.prepend(buttonAdd); } // Add buttons Delete - if (true === this.allowDeleteValue) { + if (this.allowDeleteValue) { for (let i = 0; i < this.entryTargets.length; i++) { - let entry = this.entryTargets[i]; + const entry = this.entryTargets[i]; this._addDeleteButton(entry, i); } } @@ -95,7 +100,7 @@ export default class extends Controller { } delete(event) { - let entry = event.target.closest('[data-' + this.controllerName + '-target="entry"]'); + const entry = event.target.closest('[data-' + this.controllerName + '-target="entry"]'); this._dispatchEvent('form-collection:pre-delete', { index: entry.dataset.indexEntry, @@ -112,16 +117,13 @@ export default class extends Controller { /** * Add the delete button to the entry - * @param String entry - * @param Number index - * @returns {ChildNode} * @private */ - _addDeleteButton(entry, index) { + _addDeleteButton(entry: HTMLElement, index: number) { // link the button and the entry by the data-index-entry attribute - entry.dataset.indexEntry = index; + entry.dataset.indexEntry = index.toString(); - let buttonDelete = this._textToNode(this.buttonDeleteValue); + const buttonDelete = this._textToNode(this.buttonDeleteValue); if (!buttonDelete) { return entry; } @@ -138,22 +140,17 @@ export default class extends Controller { /** * Convert text to Element to insert in the DOM - * @param String text - * @returns {ChildNode} * @private */ - _textToNode(text) { - let template = document.createElement('template'); + _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, payload = null, canBubble = false, cancelable = false) { - const userEvent = document.createEvent('CustomEvent'); - userEvent.initCustomEvent(name, canBubble, cancelable, payload); - - this.element.dispatchEvent(userEvent); + _dispatchEvent(name: string, payload: any) { + this.element.dispatchEvent(new CustomEvent(name, { detail: payload })); } } diff --git a/src/FormCollection/Resources/assets/test/controller.test.js b/src/FormCollection/Resources/assets/test/controller.test.ts similarity index 95% rename from src/FormCollection/Resources/assets/test/controller.test.js rename to src/FormCollection/Resources/assets/test/controller.test.ts index ea9b89903df..62087209499 100644 --- a/src/FormCollection/Resources/assets/test/controller.test.js +++ b/src/FormCollection/Resources/assets/test/controller.test.ts @@ -12,7 +12,7 @@ import { Application, Controller } from 'stimulus'; import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; -import FormCollectionController from '../dist/controller'; +import FormCollectionController from '../src/controller'; // Controller used to check the actual controller was properly booted class CheckController extends Controller { @@ -33,7 +33,7 @@ const startStimulus = () => { }; describe('FormCollectionController', () => { - let container; + let container: HTMLElement; beforeEach(() => { container = mountDOM(` From 480dbb508f37521acf52751e1c9e3aa3c0b40d6c Mon Sep 17 00:00:00 2001 From: akester Date: Sat, 21 May 2022 22:33:34 +0200 Subject: [PATCH 26/34] fix TU --- .../Resources/assets/test/controller.test.ts | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/FormCollection/Resources/assets/test/controller.test.ts b/src/FormCollection/Resources/assets/test/controller.test.ts index 62087209499..bad41d3fbf4 100644 --- a/src/FormCollection/Resources/assets/test/controller.test.ts +++ b/src/FormCollection/Resources/assets/test/controller.test.ts @@ -9,7 +9,7 @@ 'use strict'; -import { Application, Controller } from 'stimulus'; +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'; @@ -30,29 +30,35 @@ const startStimulus = () => { const application = Application.start(); application.register('check', CheckController); application.register('formCollection', FormCollectionController); + + return application; }; describe('FormCollectionController', () => { - let container: HTMLElement; - - beforeEach(() => { - container = mountDOM(` -
- -
- `); - }); + let application; afterEach(() => { clearDOM(); + application.stop(); }); it('events', async () => { - expect(getByTestId(container, 'container')).not.toHaveClass('connected'); + const container = mountDOM(` +
+
+ `); + expect(getByTestId(container, 'container')).not.toHaveClass('pre-connected'); + expect(getByTestId(container, 'container')).not.toHaveClass('connected'); + + application = startStimulus(); - startStimulus(); - await waitFor(() => expect(getByTestId(container, 'container')).toHaveClass('connected')); - await waitFor(() => expect(getByTestId(container, 'container')).toHaveClass('pre-connected')); + await waitFor(() => { + expect(getByTestId(container, 'container')).toHaveClass('pre-connected') + expect(getByTestId(container, 'container')).toHaveClass('connected') + }); }); }); From 3223fae4687ba83ae4432da2d80942685eeff884 Mon Sep 17 00:00:00 2001 From: akester Date: Sat, 21 May 2022 22:33:55 +0200 Subject: [PATCH 27/34] change buttons attr --- src/FormCollection/Form/UXCollectionType.php | 40 +++++++++++++------ src/FormCollection/README.md | 16 ++++---- .../Resources/assets/src/controller.ts | 2 +- .../Resources/views/form_theme_div.html.twig | 5 +-- .../views/form_theme_table.html.twig | 7 ++-- 5 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/FormCollection/Form/UXCollectionType.php b/src/FormCollection/Form/UXCollectionType.php index 095807cb0e5..42affc88800 100644 --- a/src/FormCollection/Form/UXCollectionType.php +++ b/src/FormCollection/Form/UXCollectionType.php @@ -15,6 +15,7 @@ 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; /** @@ -30,27 +31,42 @@ public function getParent() public function configureOptions(OptionsResolver $resolver) { + $defaultButtonAddOptions = [ + 'label' => 'Add', + 'class' => '', + ]; + $defaultButtonDeleteOptions = [ + 'label' => 'Remove', + 'class' => '', + ]; $resolver->setDefaults([ - 'button_add_text' => 'Add', - 'button_add_class' => '', - 'button_delete_text' => 'Remove', - 'button_delete_class' => '', + 'button_add_options' => $defaultButtonAddOptions, + 'button_delete_options' => $defaultButtonDeleteOptions, ]); - $resolver->setAllowedTypes('button_add_text', 'string'); - $resolver->setAllowedTypes('button_add_class', 'string'); - $resolver->setAllowedTypes('button_delete_text', 'string'); - $resolver->setAllowedTypes('button_delete_class', 'string'); + $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_text'] = $options['button_add_text']; - $view->vars['button_add_class'] = $options['button_add_class']; - $view->vars['button_delete_text'] = $options['button_delete_text']; - $view->vars['button_delete_class'] = $options['button_delete_class']; + $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']; } diff --git a/src/FormCollection/README.md b/src/FormCollection/README.md index 8de796c67cd..d34a380e400 100644 --- a/src/FormCollection/README.md +++ b/src/FormCollection/README.md @@ -91,14 +91,14 @@ class BlogFormType extends AbstractType // ... ->add('comments', UXCollectionType::class, [ // ... - // Default text for the add button (used by predefined theme) - 'button_add_text' => 'Add', - // Add HTML classes to the add button (used by predefined theme) - 'button_add_class' => 'btn btn-outline-primary', - // Default text for the delete button (used by predefined theme) - 'button_delete_text' => 'Remove', - // Add HTML classes to the add button (used by predefined theme) - 'button_delete_class' => 'btn btn-outline-secondary', + '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) + ], ]) // ... ; diff --git a/src/FormCollection/Resources/assets/src/controller.ts b/src/FormCollection/Resources/assets/src/controller.ts index fcc288fe3ab..eebd4ab9e9d 100644 --- a/src/FormCollection/Resources/assets/src/controller.ts +++ b/src/FormCollection/Resources/assets/src/controller.ts @@ -30,7 +30,7 @@ export default class extends Controller { controllerName: string; - entryTargets: Array; + entryTargets: Array = []; connect() { this.controllerName = this.context.scope.identifier; diff --git a/src/FormCollection/Resources/views/form_theme_div.html.twig b/src/FormCollection/Resources/views/form_theme_div.html.twig index 3380930e574..9c74d0f9140 100644 --- a/src/FormCollection/Resources/views/form_theme_div.html.twig +++ b/src/FormCollection/Resources/views/form_theme_div.html.twig @@ -1,14 +1,13 @@ {%- block button_add -%} {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#add')|trim -%} + class="{{ button_add_options.class }}" type="button">{{ button_add_options.label|trans }} {%- endblock button_add -%} {%- block button_delete -%} {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#delete')|trim -%} + class="{{ button_delete_options.class }}" type="button">{{ button_delete_options.label|trans }} {%- endblock button_delete -%} {% block ux_collection_widget -%} diff --git a/src/FormCollection/Resources/views/form_theme_table.html.twig b/src/FormCollection/Resources/views/form_theme_table.html.twig index 77af4be8510..59aacd3f759 100644 --- a/src/FormCollection/Resources/views/form_theme_table.html.twig +++ b/src/FormCollection/Resources/views/form_theme_table.html.twig @@ -3,16 +3,15 @@
{%- endblock button_add -%} {%- block button_delete -%} {%- set attrDataAction = (attr['data-action']|default('') ~ ' ' ~ controllerName ~ '#delete')|trim -%} - + {%- endblock button_delete -%} {% block ux_collection_widget -%} From 911f18f63d295488072a27b5aa3b138c3c70b884 Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Mon, 18 Jul 2022 23:03:34 +0200 Subject: [PATCH 28/34] Move logic from twig files to form types --- .../Form/UXCollectionEntryToolbarType.php | 43 ++++++ .../Form/UXCollectionEntryType.php | 66 +++++++++ .../Form/UXCollectionToolbarType.php | 43 ++++++ src/FormCollection/Form/UXCollectionType.php | 106 ++++++++------ src/FormCollection/README.md | 132 ++++++++---------- .../Resources/views/form_theme_div.html.twig | 51 ------- .../views/form_theme_table.html.twig | 55 -------- 7 files changed, 273 insertions(+), 223 deletions(-) create mode 100644 src/FormCollection/Form/UXCollectionEntryToolbarType.php create mode 100644 src/FormCollection/Form/UXCollectionEntryType.php create mode 100644 src/FormCollection/Form/UXCollectionToolbarType.php delete mode 100644 src/FormCollection/Resources/views/form_theme_div.html.twig delete mode 100644 src/FormCollection/Resources/views/form_theme_table.html.twig diff --git a/src/FormCollection/Form/UXCollectionEntryToolbarType.php b/src/FormCollection/Form/UXCollectionEntryToolbarType.php new file mode 100644 index 00000000000..b7c2f31cf67 --- /dev/null +++ b/src/FormCollection/Form/UXCollectionEntryToolbarType.php @@ -0,0 +1,43 @@ +add( + 'uxCollectionEntryDeleteButton', + ButtonType::class, + $options['delete_options'] + ); + } + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'label' => false, + 'allow_delete' => false, + 'delete_options' => [], + ]); + } +} diff --git a/src/FormCollection/Form/UXCollectionEntryType.php b/src/FormCollection/Form/UXCollectionEntryType.php new file mode 100644 index 00000000000..7f72add0934 --- /dev/null +++ b/src/FormCollection/Form/UXCollectionEntryType.php @@ -0,0 +1,66 @@ +add( + 'entry', + $options['entry_type'], + $options['entry_options'] + ); + } + + /** + * {@inheritdoc} + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $form->add('toolbar', UXCollectionEntryToolbarType::class, [ + 'allow_delete' => $options['allow_delete'], + 'delete_options' => $options['delete_options'], + ]); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'entry_type' => TextType::class, + 'entry_options' => [], + 'label' => false, + 'allow_delete' => false, + 'delete_options' => [], + 'row_attr' => [ + 'data-collection-target' => 'entry', + ], + ]); + + $entryOptionsNormalizer = function (OptionsResolver $options, $value) { + $value['inherit_data'] = true; + + return $value; + }; + + $resolver->addNormalizer('entry_options', $entryOptionsNormalizer); + } +} diff --git a/src/FormCollection/Form/UXCollectionToolbarType.php b/src/FormCollection/Form/UXCollectionToolbarType.php new file mode 100644 index 00000000000..4c79d9aab1b --- /dev/null +++ b/src/FormCollection/Form/UXCollectionToolbarType.php @@ -0,0 +1,43 @@ +add( + 'uxCollectionAddButton', + ButtonType::class, + $options['add_options'] + ); + } + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'label' => false, + 'allow_add' => false, + 'add_options' => [], + ]); + } +} diff --git a/src/FormCollection/Form/UXCollectionType.php b/src/FormCollection/Form/UXCollectionType.php index 42affc88800..f90ab960e28 100644 --- a/src/FormCollection/Form/UXCollectionType.php +++ b/src/FormCollection/Form/UXCollectionType.php @@ -1,18 +1,12 @@ - * - * 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\ButtonType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\Options; @@ -29,49 +23,77 @@ public function getParent() return CollectionType::class; } - public function configureOptions(OptionsResolver $resolver) + /** + * {@inheritdoc} + */ + public function buildView(FormView $view, FormInterface $form, array $options): void { - $defaultButtonAddOptions = [ - 'label' => 'Add', - 'class' => '', - ]; - $defaultButtonDeleteOptions = [ - 'label' => 'Remove', - 'class' => '', - ]; - $resolver->setDefaults([ - 'button_add_options' => $defaultButtonAddOptions, - 'button_delete_options' => $defaultButtonDeleteOptions, + $form->add('toolbar', UXCollectionToolbarType::class, [ + 'allow_add' => $options['allow_add'], + 'add_options' => $options['add_options'], ]); + } - $resolver->setAllowedTypes('button_add_options', 'array'); - $resolver->setAllowedTypes('button_delete_options', 'array'); + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $addOptionsNormalizer = function (Options $options, $value) { + $value['block_name'] = 'add_button'; + $value['attr'] = array_merge([ + 'data-collection-target' => 'addButton', + 'data-action' => 'collection#add', + ], $value['attr'] ?? []); + + return $value; + }; - $resolver->setNormalizer('button_add_options', function (Options $options, $value) use ($defaultButtonAddOptions) { - $value['label'] = $value['label'] ?? $defaultButtonAddOptions['label']; - $value['class'] = $value['class'] ?? $defaultButtonAddOptions['class']; + $deleteOptionsNormalizer = function (Options $options, $value) { + $value['block_name'] = 'delete_button'; + $value['attr'] = array_merge([ + 'data-collection-target' => 'deleteButton', + 'data-action' => 'collection#delete', + ], $value['attr'] ?? []); 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']; + }; + + $attrNormalizer = function (Options $options, $value) { + $value['data-controller'] = 'collection'; return $value; - }); - } + }; - public function finishView(FormView $view, FormInterface $form, array $options) - { - parent::finishView($view, $form, $options); + $entryTypeNormalizer = function (OptionsResolver $options, $value) { + return UXCollectionEntryType::class; + }; - $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']; - } + $entryOptionsNormalizer = function (OptionsResolver $options, $value) { + $value['row_attr']['data-controller-target'] = 'entry'; - public function getBlockPrefix() - { - return 'ux_collection'; + return [ + 'row_attr' => [ + 'data-controller-target' => 'entry', + ], + 'allow_delete' => $options['allow_delete'], + 'delete_options' => $options['delete_options'], + 'entry_type' => $options['ux_entry_type'] ?? null, + 'entry_options' => $value, + ]; + }; + + $resolver->setDefaults([ + 'ux_entry_type' => TextType::class, + 'add_options' => [], + 'delete_options' => [], + 'original_entry_type' => [], + ]); + + $resolver->setNormalizer('add_options', $addOptionsNormalizer); + $resolver->setNormalizer('delete_options', $deleteOptionsNormalizer); + $resolver->setNormalizer('attr', $attrNormalizer); + $resolver->addNormalizer('entry_type', $entryTypeNormalizer); + $resolver->addNormalizer('entry_options', $entryOptionsNormalizer); } } diff --git a/src/FormCollection/README.md b/src/FormCollection/README.md index d34a380e400..c627a4d2ebb 100644 --- a/src/FormCollection/README.md +++ b/src/FormCollection/README.md @@ -20,60 +20,6 @@ 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 @@ -87,21 +33,21 @@ 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) - ], - ]) - // ... - ; + $builder->add('comments', UXCollectionType::class, [ + 'label' => 'Comments', + 'ux_entry_type' => CommentType::class, + 'entry_options' => [ + 'label' => false, + ], + 'allow_add' => true, + 'allow_delete' => true, + 'add_options' => [ + 'label' => 'Add comment', + ], + 'delete_options' => [ + 'label' => 'Remove Comment', + ], + ]); } // ... @@ -111,13 +57,49 @@ class BlogFormType extends AbstractType You can display it using Twig as you would normally with any form: ```twig -{# edit.html.twig #} +{{ form(form) }} +``` + +## Theming + +### Change position of the entry toolbar + +```twig +{%- block ux_collection_entry_widget -%} + {%- set toolbar -%} + {{- form_widget(form.toolbar) -}} + {%- endset -%} + +
+ {{- toolbar -}} + + {{- block('form_rows') -}} + +
+ {{- toolbar -}} +
+
+{%- endblock -%} +``` + +### Change entry toolbar + +```twig +{%- block ux_collection_entry_toolbar_widget -%} +
+ {{- block('form_widget') -}} +
+{%- endblock -%} +``` + +### Change collection toolbar -{{ form_start(form) }} - {# ... #} - {{ form_row(comments) }} - {# ... #} -{{ form_end(form) }} +```twig +{%- block ux_collection_toolbar_widget -%} +
+ {{- block('form_widget') -}} +
+{%- endblock -%} ``` ### Extend the default behavior diff --git a/src/FormCollection/Resources/views/form_theme_div.html.twig b/src/FormCollection/Resources/views/form_theme_div.html.twig deleted file mode 100644 index 9c74d0f9140..00000000000 --- a/src/FormCollection/Resources/views/form_theme_div.html.twig +++ /dev/null @@ -1,51 +0,0 @@ -{%- 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 deleted file mode 100644 index 59aacd3f759..00000000000 --- a/src/FormCollection/Resources/views/form_theme_table.html.twig +++ /dev/null @@ -1,55 +0,0 @@ -{%- 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) %} - -
-
+ class="{{ button_add_options.class }}" type="button">{{ button_add_options.label|trans }}
- -
- {%- 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 %} From 4cfa27d3d7f7643c2ed0441c2477d9ef2b695bec Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Mon, 18 Jul 2022 23:17:31 +0200 Subject: [PATCH 29/34] Fix types and not used imports --- .../Form/UXCollectionEntryToolbarType.php | 14 +++----------- .../Form/UXCollectionEntryType.php | 18 +++++------------- .../Form/UXCollectionToolbarType.php | 14 +++----------- src/FormCollection/Form/UXCollectionType.php | 14 +++----------- 4 files changed, 14 insertions(+), 46 deletions(-) diff --git a/src/FormCollection/Form/UXCollectionEntryToolbarType.php b/src/FormCollection/Form/UXCollectionEntryToolbarType.php index b7c2f31cf67..a84cfe38238 100644 --- a/src/FormCollection/Form/UXCollectionEntryToolbarType.php +++ b/src/FormCollection/Form/UXCollectionEntryToolbarType.php @@ -4,12 +4,7 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ButtonType; -use Symfony\Component\Form\Extension\Core\Type\CollectionType; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Form\FormInterface; -use Symfony\Component\Form\FormView; -use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -18,21 +13,18 @@ */ class UXCollectionEntryToolbarType extends AbstractType { - public function buildForm(FormBuilderInterface $builder, array $options) + public function buildForm(FormBuilderInterface $builder, array $options): void { if ($options['allow_delete']) { $builder->add( 'uxCollectionEntryDeleteButton', ButtonType::class, - $options['delete_options'] + $options['delete_options'], ); } } - /** - * {@inheritdoc} - */ - public function configureOptions(OptionsResolver $resolver) + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'label' => false, diff --git a/src/FormCollection/Form/UXCollectionEntryType.php b/src/FormCollection/Form/UXCollectionEntryType.php index 7f72add0934..40fdab46bfa 100644 --- a/src/FormCollection/Form/UXCollectionEntryType.php +++ b/src/FormCollection/Form/UXCollectionEntryType.php @@ -3,34 +3,29 @@ namespace Symfony\UX\FormCollection\Form; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\ButtonType; -use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; -use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; /** * @final * @experimental - * @internal Only for internal usage to add the toolbar to every entry of a collection. + * + * @internal only for internal usage to add the toolbar to every entry of a collection */ class UXCollectionEntryType extends AbstractType { - public function buildForm(FormBuilderInterface $builder, array $options) + public function buildForm(FormBuilderInterface $builder, array $options): void { $builder->add( 'entry', $options['entry_type'], - $options['entry_options'] + $options['entry_options'], ); } - /** - * {@inheritdoc} - */ public function buildView(FormView $view, FormInterface $form, array $options): void { $form->add('toolbar', UXCollectionEntryToolbarType::class, [ @@ -39,10 +34,7 @@ public function buildView(FormView $view, FormInterface $form, array $options): ]); } - /** - * {@inheritdoc} - */ - public function configureOptions(OptionsResolver $resolver) + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'entry_type' => TextType::class, diff --git a/src/FormCollection/Form/UXCollectionToolbarType.php b/src/FormCollection/Form/UXCollectionToolbarType.php index 4c79d9aab1b..c3b6f567dee 100644 --- a/src/FormCollection/Form/UXCollectionToolbarType.php +++ b/src/FormCollection/Form/UXCollectionToolbarType.php @@ -4,12 +4,7 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ButtonType; -use Symfony\Component\Form\Extension\Core\Type\CollectionType; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Form\FormInterface; -use Symfony\Component\Form\FormView; -use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -18,21 +13,18 @@ */ class UXCollectionToolbarType extends AbstractType { - public function buildForm(FormBuilderInterface $builder, array $options) + public function buildForm(FormBuilderInterface $builder, array $options): void { if ($options['allow_add']) { $builder->add( 'uxCollectionAddButton', ButtonType::class, - $options['add_options'] + $options['add_options'], ); } } - /** - * {@inheritdoc} - */ - public function configureOptions(OptionsResolver $resolver) + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'label' => false, diff --git a/src/FormCollection/Form/UXCollectionType.php b/src/FormCollection/Form/UXCollectionType.php index f90ab960e28..c746f0a2d16 100644 --- a/src/FormCollection/Form/UXCollectionType.php +++ b/src/FormCollection/Form/UXCollectionType.php @@ -3,10 +3,8 @@ namespace Symfony\UX\FormCollection\Form; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\ButtonType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\TextType; -use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\Options; @@ -18,14 +16,11 @@ */ class UXCollectionType extends AbstractType { - public function getParent() + public function getParent(): string { return CollectionType::class; } - /** - * {@inheritdoc} - */ public function buildView(FormView $view, FormInterface $form, array $options): void { $form->add('toolbar', UXCollectionToolbarType::class, [ @@ -34,14 +29,11 @@ public function buildView(FormView $view, FormInterface $form, array $options): ]); } - /** - * {@inheritdoc} - */ public function configureOptions(OptionsResolver $resolver): void { $addOptionsNormalizer = function (Options $options, $value) { $value['block_name'] = 'add_button'; - $value['attr'] = array_merge([ + $value['attr'] = \array_merge([ 'data-collection-target' => 'addButton', 'data-action' => 'collection#add', ], $value['attr'] ?? []); @@ -51,7 +43,7 @@ public function configureOptions(OptionsResolver $resolver): void $deleteOptionsNormalizer = function (Options $options, $value) { $value['block_name'] = 'delete_button'; - $value['attr'] = array_merge([ + $value['attr'] = \array_merge([ 'data-collection-target' => 'deleteButton', 'data-action' => 'collection#delete', ], $value['attr'] ?? []); From 0976a9e78275b37737daaff8d43d615a77d3768f Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Mon, 18 Jul 2022 23:19:06 +0200 Subject: [PATCH 30/34] Allow overriding the data-controller via attr --- src/FormCollection/Form/UXCollectionType.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/FormCollection/Form/UXCollectionType.php b/src/FormCollection/Form/UXCollectionType.php index c746f0a2d16..7a69d5a5b5d 100644 --- a/src/FormCollection/Form/UXCollectionType.php +++ b/src/FormCollection/Form/UXCollectionType.php @@ -52,7 +52,9 @@ public function configureOptions(OptionsResolver $resolver): void }; $attrNormalizer = function (Options $options, $value) { - $value['data-controller'] = 'collection'; + if (!isset($value['data-controller'])) { + $value['data-controller'] = 'collection'; + } return $value; }; From dc18974cce25866c2efca0fd9b30eb2e8ae2e9b9 Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Tue, 19 Jul 2022 00:36:14 +0200 Subject: [PATCH 31/34] Simplify controller integration --- src/FormCollection/Form/UXCollectionType.php | 4 +- .../Resources/assets/dist/controller.js | 95 ++++-------- .../Resources/assets/src/controller.ts | 145 +++++------------- 3 files changed, 63 insertions(+), 181 deletions(-) diff --git a/src/FormCollection/Form/UXCollectionType.php b/src/FormCollection/Form/UXCollectionType.php index 7a69d5a5b5d..fdedcec0294 100644 --- a/src/FormCollection/Form/UXCollectionType.php +++ b/src/FormCollection/Form/UXCollectionType.php @@ -64,11 +64,9 @@ public function configureOptions(OptionsResolver $resolver): void }; $entryOptionsNormalizer = function (OptionsResolver $options, $value) { - $value['row_attr']['data-controller-target'] = 'entry'; - return [ 'row_attr' => [ - 'data-controller-target' => 'entry', + 'data-collection-target' => 'entry', ], 'allow_delete' => $options['allow_delete'], 'delete_options' => $options['delete_options'], diff --git a/src/FormCollection/Resources/assets/dist/controller.js b/src/FormCollection/Resources/assets/dist/controller.js index 8e5ae980139..2f11ddec5bb 100644 --- a/src/FormCollection/Resources/assets/dist/controller.js +++ b/src/FormCollection/Resources/assets/dist/controller.js @@ -1,105 +1,62 @@ import { Controller } from '@hotwired/stimulus'; -class default_1 extends Controller { +class controller extends Controller { constructor() { super(...arguments); this.index = 0; + this.controllerName = 'collection'; + } + static get targets() { + return ['entry', 'addButton', 'removeButton']; } 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, - }); + this._dispatchEvent('form-collection:pre-connect'); + this.index = this.entryTargets.length; + this._dispatchEvent('form-collection:connect'); } add() { - this.index++; - let newEntry = this.element.dataset.prototype; - if (!newEntry) { - newEntry = this.prototypeValue; + const prototype = this.element.dataset.prototype; + if (!prototype) { + throw new Error('A "data-prototype" attribute was expected on data-controller="' + this.controllerName + '" element.'); } - 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', { + prototype: prototype, index: this.index, - element: newEntry, }); - this.element.append(newEntry); - let entry = this.entryTargets[this.entryTargets.length - 1]; - entry = this._addDeleteButton(entry, this.index); + const newEntry = this._textToNode(prototype.replace(/__name__/g, this.index.toString())); + if (this.entryTargets.length > 1) { + this.entryTargets[this.entryTargets.length - 1].after(newEntry); + } + else { + this.element.prepend(newEntry); + } this._dispatchEvent('form-collection:add', { + prototype: prototype, index: this.index, - element: entry, }); + this.index++; } delete(event) { - const entry = event.target.closest('[data-' + this.controllerName + '-target="entry"]'); + const clickTarget = event.target; + const entry = clickTarget.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 })); + _dispatchEvent(name, payload = {}) { + this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); } } -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 }; +export { controller as default }; diff --git a/src/FormCollection/Resources/assets/src/controller.ts b/src/FormCollection/Resources/assets/src/controller.ts index eebd4ab9e9d..485cf2a9c39 100644 --- a/src/FormCollection/Resources/assets/src/controller.ts +++ b/src/FormCollection/Resources/assets/src/controller.ts @@ -3,154 +3,81 @@ 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 = []; + static get targets() { + return ['entry', 'addButton', 'removeButton']; + } - connect() { - this.controllerName = this.context.scope.identifier; - this.index = this.startIndexValue ? this.startIndexValue : this.entryTargets.length - 1; + declare readonly entryTargets: HTMLElement[]; - if (!this.prototypeNameValue) { - this.prototypeNameValue = '__name__'; - } + index: Number = 0; + controllerName: string = 'collection'; - this._dispatchEvent('form-collection:pre-connect', { - allowAdd: this.allowAddValue, - allowDelete: this.allowDeleteValue, - }); + connect() { + this.controllerName = this.context.scope.identifier; - if (this.allowAddValue) { - // Add button Add - const buttonAdd = this._textToNode(this.buttonAddValue); - this.element.prepend(buttonAdd); - } + this._dispatchEvent('form-collection:pre-connect'); - // 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.index = this.entryTargets.length; - this._dispatchEvent('form-collection:connect', { - allowAdd: this.allowAddValue, - allowDelete: this.allowDeleteValue, - }); + this._dispatchEvent('form-collection:connect'); } add() { - this.index++; + const prototype = this.element.dataset.prototype; - // Compute the new entry - let newEntry = this.element.dataset.prototype; - if (!newEntry) { - newEntry = this.prototypeValue; + if (!prototype) { + throw new Error( + 'A "data-prototype" attribute was expected on data-controller="' + this.controllerName + '" element.' + ); } - 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', { + prototype: prototype, index: this.index, - element: newEntry, }); - this.element.append(newEntry); + const newEntry = this._textToNode(prototype.replace(/__name__/g, this.index.toString())); - // 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); + if (this.entryTargets.length > 1) { + this.entryTargets[this.entryTargets.length - 1].after(newEntry); + } else { + this.element.prepend(newEntry); + } this._dispatchEvent('form-collection:add', { + prototype: prototype, index: this.index, - element: entry, }); + + this.index++; } - delete(event) { - const entry = event.target.closest('[data-' + this.controllerName + '-target="entry"]'); + delete(event: MouseEvent) { + const clickTarget = event.target as HTMLButtonElement; + + const entry = clickTarget.closest('[data-' + this.controllerName + '-target="entry"]') as HTMLElement; 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) { + _textToNode(text: string): HTMLElement { const template = document.createElement('template'); - text = text.trim(); // Never return a text node of whitespace as the result + text = text.trim(); + template.innerHTML = text; - return template.content.firstChild; + return template.content.firstChild as HTMLElement; } - _dispatchEvent(name: string, payload: any) { - this.element.dispatchEvent(new CustomEvent(name, { detail: payload })); + _dispatchEvent(name: string, payload: {} = {}) { + this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); } } From 9ee00dcb0bb5fe724da46c6168754ba078ede4c8 Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Tue, 19 Jul 2022 01:34:58 +0200 Subject: [PATCH 32/34] Fix handling of nested blocks --- src/FormCollection/Form/UXCollectionType.php | 12 +++--- .../Resources/assets/dist/controller.js | 36 +++++++++------- .../Resources/assets/src/controller.ts | 41 +++++++++++-------- 3 files changed, 53 insertions(+), 36 deletions(-) diff --git a/src/FormCollection/Form/UXCollectionType.php b/src/FormCollection/Form/UXCollectionType.php index fdedcec0294..ca41dfbeef5 100644 --- a/src/FormCollection/Form/UXCollectionType.php +++ b/src/FormCollection/Form/UXCollectionType.php @@ -34,8 +34,8 @@ public function configureOptions(OptionsResolver $resolver): void $addOptionsNormalizer = function (Options $options, $value) { $value['block_name'] = 'add_button'; $value['attr'] = \array_merge([ - 'data-collection-target' => 'addButton', - 'data-action' => 'collection#add', + 'data-' . $options['attr']['data-controller'] . '-target' => 'addButton', + 'data-action' => $options['attr']['data-controller'] . '#add', ], $value['attr'] ?? []); return $value; @@ -44,8 +44,8 @@ public function configureOptions(OptionsResolver $resolver): void $deleteOptionsNormalizer = function (Options $options, $value) { $value['block_name'] = 'delete_button'; $value['attr'] = \array_merge([ - 'data-collection-target' => 'deleteButton', - 'data-action' => 'collection#delete', + 'data-' . $options['attr']['data-controller'] . '-target' => 'deleteButton', + 'data-action' => $options['attr']['data-controller'] . '#delete', ], $value['attr'] ?? []); return $value; @@ -55,6 +55,7 @@ public function configureOptions(OptionsResolver $resolver): void if (!isset($value['data-controller'])) { $value['data-controller'] = 'collection'; } + $value['data-' . $value['data-controller'] . '-prototype-name-value'] = $options['prototype_name']; return $value; }; @@ -66,8 +67,9 @@ public function configureOptions(OptionsResolver $resolver): void $entryOptionsNormalizer = function (OptionsResolver $options, $value) { return [ 'row_attr' => [ - 'data-collection-target' => 'entry', + 'data-' . $options['attr']['data-controller'] . '-target' => 'entry', ], + 'label' => false, 'allow_delete' => $options['allow_delete'], 'delete_options' => $options['delete_options'], 'entry_type' => $options['ux_entry_type'] ?? null, diff --git a/src/FormCollection/Resources/assets/dist/controller.js b/src/FormCollection/Resources/assets/dist/controller.js index 2f11ddec5bb..a1bda540a7a 100644 --- a/src/FormCollection/Resources/assets/dist/controller.js +++ b/src/FormCollection/Resources/assets/dist/controller.js @@ -1,38 +1,40 @@ import { Controller } from '@hotwired/stimulus'; -class controller extends Controller { +class default_1 extends Controller { constructor() { super(...arguments); this.index = 0; this.controllerName = 'collection'; - } - static get targets() { - return ['entry', 'addButton', 'removeButton']; + this.entries = []; } connect() { this.controllerName = this.context.scope.identifier; this._dispatchEvent('form-collection:pre-connect'); - this.index = this.entryTargets.length; + this.entries = []; + this.element.querySelectorAll(':scope > [data-' + this.controllerName + '-target="entry"]').forEach(entry => { + this.entries.push(entry); + }); this._dispatchEvent('form-collection:connect'); } add() { - const prototype = this.element.dataset.prototype; - if (!prototype) { + const prototypeHTML = this.element.dataset.prototype; + if (!prototypeHTML) { throw new Error('A "data-prototype" attribute was expected on data-controller="' + this.controllerName + '" element.'); } + const newEntry = this._textToNode(prototypeHTML.replace(new RegExp('/' + this.prototypeNameValue + '/', 'g'), this.index.toString())); this._dispatchEvent('form-collection:pre-add', { - prototype: prototype, + entry: newEntry, index: this.index, }); - const newEntry = this._textToNode(prototype.replace(/__name__/g, this.index.toString())); - if (this.entryTargets.length > 1) { - this.entryTargets[this.entryTargets.length - 1].after(newEntry); + if (this.entries.length > 1) { + this.entries[this.entries.length - 1].after(newEntry); } else { this.element.prepend(newEntry); } + this.entries.push(newEntry); this._dispatchEvent('form-collection:add', { - prototype: prototype, + entry: newEntry, index: this.index, }); this.index++; @@ -41,11 +43,12 @@ class controller extends Controller { const clickTarget = event.target; const entry = clickTarget.closest('[data-' + this.controllerName + '-target="entry"]'); this._dispatchEvent('form-collection:pre-delete', { - element: entry, + entry: entry, }); entry.remove(); + this.entries = this.entries.filter(currentEntry => currentEntry !== entry); this._dispatchEvent('form-collection:delete', { - element: entry, + entry: entry, }); } _textToNode(text) { @@ -58,5 +61,8 @@ class controller extends Controller { this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); } } +default_1.values = { + prototypeName: String, +}; -export { controller as default }; +export { default_1 as default }; diff --git a/src/FormCollection/Resources/assets/src/controller.ts b/src/FormCollection/Resources/assets/src/controller.ts index 485cf2a9c39..4fc84927266 100644 --- a/src/FormCollection/Resources/assets/src/controller.ts +++ b/src/FormCollection/Resources/assets/src/controller.ts @@ -3,49 +3,57 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { - static get targets() { - return ['entry', 'addButton', 'removeButton']; + static values = { + prototypeName: String, } - declare readonly entryTargets: HTMLElement[]; + declare readonly prototypeNameValue: string; - index: Number = 0; - controllerName: string = 'collection'; + index = 0; + controllerName = 'collection'; + entries: Element[] = []; connect() { this.controllerName = this.context.scope.identifier; this._dispatchEvent('form-collection:pre-connect'); - this.index = this.entryTargets.length; + this.entries = []; + this.element.querySelectorAll(':scope > [data-' + this.controllerName + '-target="entry"]').forEach(entry => { + this.entries.push(entry); + }); this._dispatchEvent('form-collection:connect'); } add() { - const prototype = this.element.dataset.prototype; + const prototypeHTML = this.element.dataset.prototype; - if (!prototype) { + if (!prototypeHTML) { throw new Error( 'A "data-prototype" attribute was expected on data-controller="' + this.controllerName + '" element.' ); } + const newEntry = this._textToNode( + prototypeHTML.replace(new RegExp('/' + this.prototypeNameValue + '/', 'g'), this.index.toString()) + ); + this._dispatchEvent('form-collection:pre-add', { - prototype: prototype, + entry: newEntry, index: this.index, }); - const newEntry = this._textToNode(prototype.replace(/__name__/g, this.index.toString())); - - if (this.entryTargets.length > 1) { - this.entryTargets[this.entryTargets.length - 1].after(newEntry); + if (this.entries.length > 1) { + this.entries[this.entries.length - 1].after(newEntry); } else { this.element.prepend(newEntry); } + this.entries.push(newEntry); + this._dispatchEvent('form-collection:add', { - prototype: prototype, + entry: newEntry, index: this.index, }); @@ -58,13 +66,14 @@ export default class extends Controller { const entry = clickTarget.closest('[data-' + this.controllerName + '-target="entry"]') as HTMLElement; this._dispatchEvent('form-collection:pre-delete', { - element: entry, + entry: entry, }); entry.remove(); + this.entries = this.entries.filter(currentEntry => currentEntry !== entry); this._dispatchEvent('form-collection:delete', { - element: entry, + entry: entry, }); } From d29459272bdf08543e40f2f25ed337764ecba368 Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Tue, 19 Jul 2022 01:53:02 +0200 Subject: [PATCH 33/34] Move docs to index.rst --- src/FormCollection/README.md | 178 ++------------------ src/FormCollection/Resources/doc/index.rst | 180 +++++++++++++++++++++ 2 files changed, 190 insertions(+), 168 deletions(-) create mode 100644 src/FormCollection/Resources/doc/index.rst diff --git a/src/FormCollection/README.md b/src/FormCollection/README.md index c627a4d2ebb..7fd16b20a9a 100644 --- a/src/FormCollection/README.md +++ b/src/FormCollection/README.md @@ -1,172 +1,14 @@ -# UX Form Collection +# Symfony UX Form Collection -Symfony UX Form collection is a Symfony bundle providing light UX for collection -in Symfony Forms. +Symfony UX Form collection is a Symfony bundle provides a Stimulus integration +for add and remove of for [Symfony Form Collection Type](https://symfony.com/doc/current/form/collection.html). -## Installation +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ux to create issues or submit pull requests. -UX Form Collection requires PHP 7.2+ and Symfony 4.4+. +## Resources -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. - -## 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, [ - 'label' => 'Comments', - 'ux_entry_type' => CommentType::class, - 'entry_options' => [ - 'label' => false, - ], - 'allow_add' => true, - 'allow_delete' => true, - 'add_options' => [ - 'label' => 'Add comment', - ], - 'delete_options' => [ - 'label' => 'Remove Comment', - ], - ]); - } - - // ... -} -``` - -You can display it using Twig as you would normally with any form: - -```twig -{{ form(form) }} -``` - -## Theming - -### Change position of the entry toolbar - -```twig -{%- block ux_collection_entry_widget -%} - {%- set toolbar -%} - {{- form_widget(form.toolbar) -}} - {%- endset -%} - -
- {{- toolbar -}} - - {{- block('form_rows') -}} - -
- {{- toolbar -}} -
-
-{%- endblock -%} -``` - -### Change entry toolbar - -```twig -{%- block ux_collection_entry_toolbar_widget -%} -
- {{- block('form_widget') -}} -
-{%- endblock -%} -``` - -### Change collection toolbar - -```twig -{%- block ux_collection_toolbar_widget -%} -
- {{- block('form_widget') -}} -
-{%- endblock -%} -``` - -### 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' - ] - ]); -``` +- [Documentation](https://symfony.com/bundles/ux-form-collection/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/FormCollection/Resources/doc/index.rst b/src/FormCollection/Resources/doc/index.rst new file mode 100644 index 00000000000..f4101dfd133 --- /dev/null +++ b/src/FormCollection/Resources/doc/index.rst @@ -0,0 +1,180 @@ +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: + +.. code-block:: 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. + +Usage +----- + +The most common usage of Form Collection is to use it as a replacement of +the native CollectionType class: + +.. code-block:: php + + // ... + use Symfony\UX\FormCollection\Form\UXCollectionType; + + class BlogFormType extends AbstractType + { + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->add('comments', UXCollectionType::class, [ + 'label' => 'Comments', + 'ux_entry_type' => CommentType::class, + 'entry_options' => [ + 'label' => false, + ], + 'allow_add' => true, + 'allow_delete' => true, + 'add_options' => [ + 'label' => 'Add comment', + ], + 'delete_options' => [ + 'label' => 'Remove Comment', + ], + ]); + } + + // ... + } + +You can display it using Twig as you would normally with any form: + +.. code-block:: twig + + {{ form(form) }} + + +Theming +------- + +Change position of the entry toolbar +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: twig + + {%- block ux_collection_entry_widget -%} + {%- set toolbar -%} + {{- form_widget(form.toolbar) -}} + {%- endset -%} + +
+ {{- toolbar -}} + + {{- block('form_rows') -}} + +
+ {{- toolbar -}} +
+
+ {%- endblock -%} + +Change entry toolbar +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: twig + {%- block ux_collection_entry_toolbar_widget -%} +
+ {{- block('form_widget') -}} +
+ {%- endblock -%} + +Change collection toolbar +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: twig + + {%- block ux_collection_toolbar_widget -%} +
+ {{- block('form_widget') -}} +
+ {%- endblock -%} + +Extend the default behavior +--------------------------- + +Symfony UX Form Collection allows you to extend its default behavior using a custom Stimulus controller: + +.. code-block:: 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: + +.. code-block:: php + + $builder + // ... + ->add('comments', UXCollectionType::class, [ + // ... + 'attr' => [ + // Change the controller name + 'data-controller' => 'mycollection' + ] + ]); From 4edfefbca9ae4526eab69d0868372e16657bac28 Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Tue, 19 Jul 2022 19:45:36 +0200 Subject: [PATCH 34/34] Fix add problem --- src/FormCollection/Resources/assets/dist/controller.js | 2 +- src/FormCollection/Resources/assets/src/controller.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FormCollection/Resources/assets/dist/controller.js b/src/FormCollection/Resources/assets/dist/controller.js index a1bda540a7a..03d21d159a8 100644 --- a/src/FormCollection/Resources/assets/dist/controller.js +++ b/src/FormCollection/Resources/assets/dist/controller.js @@ -26,7 +26,7 @@ class default_1 extends Controller { entry: newEntry, index: this.index, }); - if (this.entries.length > 1) { + if (this.entries.length > 0) { this.entries[this.entries.length - 1].after(newEntry); } else { diff --git a/src/FormCollection/Resources/assets/src/controller.ts b/src/FormCollection/Resources/assets/src/controller.ts index 4fc84927266..65a31126842 100644 --- a/src/FormCollection/Resources/assets/src/controller.ts +++ b/src/FormCollection/Resources/assets/src/controller.ts @@ -44,7 +44,7 @@ export default class extends Controller { index: this.index, }); - if (this.entries.length > 1) { + if (this.entries.length > 0) { this.entries[this.entries.length - 1].after(newEntry); } else { this.element.prepend(newEntry);