diff --git a/src/FormCollection/.gitattributes b/src/FormCollection/.gitattributes new file mode 100644 index 00000000000..17bf7a840e9 --- /dev/null +++ b/src/FormCollection/.gitattributes @@ -0,0 +1,3 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/Resources/assets/test export-ignore diff --git a/src/FormCollection/DependencyInjection/FormCollectionExtension.php b/src/FormCollection/DependencyInjection/FormCollectionExtension.php new file mode 100644 index 00000000000..3266037fd37 --- /dev/null +++ b/src/FormCollection/DependencyInjection/FormCollectionExtension.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\FormCollection\DependencyInjection; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\UX\FormCollection\Form\UXCollectionType; + +/** + * @internal + */ +class FormCollectionExtension extends Extension +{ + public function load(array $configs, ContainerBuilder $container) + { + $container + ->setDefinition('form.ux_collection', new Definition(UXCollectionType::class)) + ->addTag('form.type') + ->setPublic(false) + ; + } +} diff --git a/src/FormCollection/Form/UXCollectionEntryToolbarType.php b/src/FormCollection/Form/UXCollectionEntryToolbarType.php new file mode 100644 index 00000000000..a84cfe38238 --- /dev/null +++ b/src/FormCollection/Form/UXCollectionEntryToolbarType.php @@ -0,0 +1,35 @@ +add( + 'uxCollectionEntryDeleteButton', + ButtonType::class, + $options['delete_options'], + ); + } + } + + public function configureOptions(OptionsResolver $resolver): void + { + $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..40fdab46bfa --- /dev/null +++ b/src/FormCollection/Form/UXCollectionEntryType.php @@ -0,0 +1,58 @@ +add( + 'entry', + $options['entry_type'], + $options['entry_options'], + ); + } + + 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'], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $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..c3b6f567dee --- /dev/null +++ b/src/FormCollection/Form/UXCollectionToolbarType.php @@ -0,0 +1,35 @@ +add( + 'uxCollectionAddButton', + ButtonType::class, + $options['add_options'], + ); + } + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'allow_add' => false, + 'add_options' => [], + ]); + } +} diff --git a/src/FormCollection/Form/UXCollectionType.php b/src/FormCollection/Form/UXCollectionType.php new file mode 100644 index 00000000000..ca41dfbeef5 --- /dev/null +++ b/src/FormCollection/Form/UXCollectionType.php @@ -0,0 +1,93 @@ +add('toolbar', UXCollectionToolbarType::class, [ + 'allow_add' => $options['allow_add'], + 'add_options' => $options['add_options'], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $addOptionsNormalizer = function (Options $options, $value) { + $value['block_name'] = 'add_button'; + $value['attr'] = \array_merge([ + 'data-' . $options['attr']['data-controller'] . '-target' => 'addButton', + 'data-action' => $options['attr']['data-controller'] . '#add', + ], $value['attr'] ?? []); + + return $value; + }; + + $deleteOptionsNormalizer = function (Options $options, $value) { + $value['block_name'] = 'delete_button'; + $value['attr'] = \array_merge([ + 'data-' . $options['attr']['data-controller'] . '-target' => 'deleteButton', + 'data-action' => $options['attr']['data-controller'] . '#delete', + ], $value['attr'] ?? []); + + return $value; + }; + + $attrNormalizer = function (Options $options, $value) { + if (!isset($value['data-controller'])) { + $value['data-controller'] = 'collection'; + } + $value['data-' . $value['data-controller'] . '-prototype-name-value'] = $options['prototype_name']; + + return $value; + }; + + $entryTypeNormalizer = function (OptionsResolver $options, $value) { + return UXCollectionEntryType::class; + }; + + $entryOptionsNormalizer = function (OptionsResolver $options, $value) { + return [ + 'row_attr' => [ + '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, + '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/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..7fd16b20a9a --- /dev/null +++ b/src/FormCollection/README.md @@ -0,0 +1,14 @@ +# Symfony UX Form Collection + +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). + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ux to create issues or submit pull requests. + +## Resources + +- [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/assets/.babelrc b/src/FormCollection/Resources/assets/.babelrc new file mode 100644 index 00000000000..77f182d2ca8 --- /dev/null +++ b/src/FormCollection/Resources/assets/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["@babel/env"], + "plugins": ["@babel/plugin-proposal-class-properties"] +} diff --git a/src/FormCollection/Resources/assets/dist/controller.js b/src/FormCollection/Resources/assets/dist/controller.js new file mode 100644 index 00000000000..03d21d159a8 --- /dev/null +++ b/src/FormCollection/Resources/assets/dist/controller.js @@ -0,0 +1,68 @@ +import { Controller } from '@hotwired/stimulus'; + +class default_1 extends Controller { + constructor() { + super(...arguments); + this.index = 0; + this.controllerName = 'collection'; + this.entries = []; + } + connect() { + this.controllerName = this.context.scope.identifier; + this._dispatchEvent('form-collection:pre-connect'); + this.entries = []; + this.element.querySelectorAll(':scope > [data-' + this.controllerName + '-target="entry"]').forEach(entry => { + this.entries.push(entry); + }); + this._dispatchEvent('form-collection:connect'); + } + add() { + 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', { + entry: newEntry, + index: this.index, + }); + if (this.entries.length > 0) { + this.entries[this.entries.length - 1].after(newEntry); + } + else { + this.element.prepend(newEntry); + } + this.entries.push(newEntry); + this._dispatchEvent('form-collection:add', { + entry: newEntry, + index: this.index, + }); + this.index++; + } + delete(event) { + const clickTarget = event.target; + const entry = clickTarget.closest('[data-' + this.controllerName + '-target="entry"]'); + this._dispatchEvent('form-collection:pre-delete', { + entry: entry, + }); + entry.remove(); + this.entries = this.entries.filter(currentEntry => currentEntry !== entry); + this._dispatchEvent('form-collection:delete', { + entry: entry, + }); + } + _textToNode(text) { + const template = document.createElement('template'); + text = text.trim(); + template.innerHTML = text; + return template.content.firstChild; + } + _dispatchEvent(name, payload = {}) { + this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); + } +} +default_1.values = { + prototypeName: String, +}; + +export { default_1 as default }; diff --git a/src/FormCollection/Resources/assets/jest.config.js b/src/FormCollection/Resources/assets/jest.config.js new file mode 100644 index 00000000000..5cc9ba23dab --- /dev/null +++ b/src/FormCollection/Resources/assets/jest.config.js @@ -0,0 +1,5 @@ +const config = require('../../../../jest.config.js'); + +config.setupFilesAfterEnv.push('./test/setup.js'); + +module.exports = config; diff --git a/src/FormCollection/Resources/assets/package.json b/src/FormCollection/Resources/assets/package.json new file mode 100644 index 00000000000..bccdc162439 --- /dev/null +++ b/src/FormCollection/Resources/assets/package.json @@ -0,0 +1,22 @@ +{ + "name": "@symfony/ux-form-collection", + "description": "UX Form Collection for Symfony Forms", + "license": "MIT", + "version": "1.0.0", + "symfony": { + "controllers": { + "collection": { + "main": "dist/controller.js", + "webpackMode": "eager", + "fetch": "eager", + "enabled": true + } + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0" + }, + "devDependencies": { + "@hotwired/stimulus": "^3.0.0" + } +} diff --git a/src/FormCollection/Resources/assets/src/controller.ts b/src/FormCollection/Resources/assets/src/controller.ts new file mode 100644 index 00000000000..65a31126842 --- /dev/null +++ b/src/FormCollection/Resources/assets/src/controller.ts @@ -0,0 +1,92 @@ +'use strict'; + +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static values = { + prototypeName: String, + } + + declare readonly prototypeNameValue: string; + + index = 0; + controllerName = 'collection'; + entries: Element[] = []; + + connect() { + this.controllerName = this.context.scope.identifier; + + this._dispatchEvent('form-collection:pre-connect'); + + this.entries = []; + this.element.querySelectorAll(':scope > [data-' + this.controllerName + '-target="entry"]').forEach(entry => { + this.entries.push(entry); + }); + + this._dispatchEvent('form-collection:connect'); + } + + add() { + 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', { + entry: newEntry, + index: this.index, + }); + + if (this.entries.length > 0) { + this.entries[this.entries.length - 1].after(newEntry); + } else { + this.element.prepend(newEntry); + } + + this.entries.push(newEntry); + + this._dispatchEvent('form-collection:add', { + entry: newEntry, + index: this.index, + }); + + this.index++; + } + + 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', { + entry: entry, + }); + + entry.remove(); + this.entries = this.entries.filter(currentEntry => currentEntry !== entry); + + this._dispatchEvent('form-collection:delete', { + entry: entry, + }); + } + + _textToNode(text: string): HTMLElement { + const template = document.createElement('template'); + text = text.trim(); + + template.innerHTML = text; + + return template.content.firstChild as HTMLElement; + } + + _dispatchEvent(name: string, payload: {} = {}) { + this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); + } +} diff --git a/src/FormCollection/Resources/assets/test/controller.test.ts b/src/FormCollection/Resources/assets/test/controller.test.ts new file mode 100644 index 00000000000..bad41d3fbf4 --- /dev/null +++ b/src/FormCollection/Resources/assets/test/controller.test.ts @@ -0,0 +1,64 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import { Application, Controller } from '@hotwired/stimulus'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import FormCollectionController from '../src/controller'; + +// Controller used to check the actual controller was properly booted +class CheckController extends Controller { + connect() { + this.element.addEventListener('form-collection:pre-connect', () => { + this.element.classList.add('pre-connected'); + }); + this.element.addEventListener('form-collection:connect', () => { + this.element.classList.add('connected'); + }); + } +} + +const startStimulus = () => { + const application = Application.start(); + application.register('check', CheckController); + application.register('formCollection', FormCollectionController); + + return application; +}; + +describe('FormCollectionController', () => { + let application; + + afterEach(() => { + clearDOM(); + application.stop(); + }); + + it('events', async () => { + const container = mountDOM(` +
+
+ `); + + expect(getByTestId(container, 'container')).not.toHaveClass('pre-connected'); + expect(getByTestId(container, 'container')).not.toHaveClass('connected'); + + application = startStimulus(); + + await waitFor(() => { + expect(getByTestId(container, 'container')).toHaveClass('pre-connected') + expect(getByTestId(container, 'container')).toHaveClass('connected') + }); + }); +}); diff --git a/src/FormCollection/Resources/assets/test/setup.js b/src/FormCollection/Resources/assets/test/setup.js new file mode 100644 index 00000000000..ddd4655c30e --- /dev/null +++ b/src/FormCollection/Resources/assets/test/setup.js @@ -0,0 +1,12 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import '@symfony/stimulus-testing/setup'; diff --git a/src/FormCollection/Resources/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' + ] + ]); 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" +}