From e901026aa1d2e2cd2b27a5796962bc8fc4b5dcca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 2 Jul 2022 23:18:27 +0200 Subject: [PATCH 1/6] Add Collection component --- src/Collection/.gitattributes | 8 ++ src/Collection/.gitignore | 12 +++ src/Collection/.symfony.bundle.yaml | 3 + src/Collection/CONTRIBUTING.md | 16 +++ src/Collection/CollectionBundle.php | 21 ++++ src/Collection/LICENSE | 19 ++++ src/Collection/README.md | 11 +++ .../Resources/assets/dist/controller.js | 71 ++++++++++++++ .../Resources/assets/jest.config.js | 1 + src/Collection/Resources/assets/package.json | 24 +++++ .../Resources/assets/src/controller.ts | 98 +++++++++++++++++++ .../Resources/assets/test/controller.test.ts | 40 ++++++++ src/Collection/Tests/app/Entity/Game.php | 40 ++++++++ src/Collection/Tests/app/Entity/Player.php | 14 +++ src/Collection/Tests/app/Entity/Team.php | 38 +++++++ src/Collection/Tests/app/Form/GameType.php | 39 ++++++++ src/Collection/Tests/app/Form/PlayerType.php | 31 ++++++ src/Collection/Tests/app/Form/TeamType.php | 36 +++++++ src/Collection/Tests/app/Kernel.php | 94 ++++++++++++++++++ src/Collection/Tests/app/assets/app.js | 16 +++ src/Collection/Tests/app/package.json | 22 +++++ src/Collection/Tests/app/public/index.php | 28 ++++++ .../Tests/app/templates/base.html.twig | 12 +++ .../Tests/app/templates/form.html.twig | 7 ++ src/Collection/Tests/app/webpack.config.js | 23 +++++ src/Collection/composer.json | 63 ++++++++++++ src/Collection/phpunit.xml.dist | 38 +++++++ 27 files changed, 825 insertions(+) create mode 100644 src/Collection/.gitattributes create mode 100644 src/Collection/.gitignore create mode 100644 src/Collection/.symfony.bundle.yaml create mode 100644 src/Collection/CONTRIBUTING.md create mode 100644 src/Collection/CollectionBundle.php create mode 100644 src/Collection/LICENSE create mode 100644 src/Collection/README.md create mode 100644 src/Collection/Resources/assets/dist/controller.js create mode 100644 src/Collection/Resources/assets/jest.config.js create mode 100644 src/Collection/Resources/assets/package.json create mode 100644 src/Collection/Resources/assets/src/controller.ts create mode 100644 src/Collection/Resources/assets/test/controller.test.ts create mode 100644 src/Collection/Tests/app/Entity/Game.php create mode 100644 src/Collection/Tests/app/Entity/Player.php create mode 100644 src/Collection/Tests/app/Entity/Team.php create mode 100644 src/Collection/Tests/app/Form/GameType.php create mode 100644 src/Collection/Tests/app/Form/PlayerType.php create mode 100644 src/Collection/Tests/app/Form/TeamType.php create mode 100644 src/Collection/Tests/app/Kernel.php create mode 100644 src/Collection/Tests/app/assets/app.js create mode 100644 src/Collection/Tests/app/package.json create mode 100644 src/Collection/Tests/app/public/index.php create mode 100644 src/Collection/Tests/app/templates/base.html.twig create mode 100644 src/Collection/Tests/app/templates/form.html.twig create mode 100644 src/Collection/Tests/app/webpack.config.js create mode 100644 src/Collection/composer.json create mode 100644 src/Collection/phpunit.xml.dist diff --git a/src/Collection/.gitattributes b/src/Collection/.gitattributes new file mode 100644 index 00000000000..3868d83c803 --- /dev/null +++ b/src/Collection/.gitattributes @@ -0,0 +1,8 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/.symfony.bundle.yaml export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon.dist export-ignore +/Resources/assets/test export-ignore +/Resources/assets/jest.config.js export-ignore +/Tests export-ignore diff --git a/src/Collection/.gitignore b/src/Collection/.gitignore new file mode 100644 index 00000000000..859b1aa2473 --- /dev/null +++ b/src/Collection/.gitignore @@ -0,0 +1,12 @@ +/.php_cs.cache +/.php_cs +/.phpunit.result.cache +/composer.phar +/composer.lock +/phpunit.xml +/vendor/ +/Tests/app/var +/Tests/app/public/build/ +node_modules/ +package-lock.json +yarn.lock diff --git a/src/Collection/.symfony.bundle.yaml b/src/Collection/.symfony.bundle.yaml new file mode 100644 index 00000000000..50b8d4a3040 --- /dev/null +++ b/src/Collection/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "Resources/doc" diff --git a/src/Collection/CONTRIBUTING.md b/src/Collection/CONTRIBUTING.md new file mode 100644 index 00000000000..fd849829c72 --- /dev/null +++ b/src/Collection/CONTRIBUTING.md @@ -0,0 +1,16 @@ +# Contributing + +Install the test app: + + $ composer install + $ cd Tests/app + $ yarn install + $ yarn build + +Start the test app: + + $ symfony serve + +## Run tests + + $ php vendor/bin/simple-phpunit diff --git a/src/Collection/CollectionBundle.php b/src/Collection/CollectionBundle.php new file mode 100644 index 00000000000..a8bee206970 --- /dev/null +++ b/src/Collection/CollectionBundle.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Collection; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +/** + * @author Kévin Dunglas + */ +final class CollectionBundle extends Bundle +{ +} diff --git a/src/Collection/LICENSE b/src/Collection/LICENSE new file mode 100644 index 00000000000..5dcd9af2f97 --- /dev/null +++ b/src/Collection/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Kévin Dunglas + +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/Collection/README.md b/src/Collection/README.md new file mode 100644 index 00000000000..35efa7c85f9 --- /dev/null +++ b/src/Collection/README.md @@ -0,0 +1,11 @@ +# Symfony UX Collection + +**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-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/Collection/Resources/assets/dist/controller.js b/src/Collection/Resources/assets/dist/controller.js new file mode 100644 index 00000000000..3496ef27f08 --- /dev/null +++ b/src/Collection/Resources/assets/dist/controller.js @@ -0,0 +1,71 @@ +import { Controller } from '@hotwired/stimulus'; + +const DEFAULT_ITEMS_SELECTOR = ':scope > :is(div, fieldset)'; +var ButtonType; +(function (ButtonType) { + ButtonType["Add"] = "add"; + ButtonType["Delete"] = "delete"; +})(ButtonType || (ButtonType = {})); +class controller extends Controller { + connect() { + this.connectCollection(this.element); + } + connectCollection(parent) { + parent.querySelectorAll('[data-prototype]').forEach((el) => { + const collectionEl = el; + const items = this.getItems(collectionEl); + collectionEl.dataset.currentIndex = items.length.toString(); + this.addAddButton(collectionEl); + this.getItems(collectionEl).forEach(itemEl => this.addDeleteButton(collectionEl, itemEl)); + }); + } + getItems(collectionElement) { + return collectionElement.querySelectorAll(collectionElement.dataset.itemsSelector || DEFAULT_ITEMS_SELECTOR); + } + createButton(collectionEl, buttonType) { + const buttonTemplateID = collectionEl.dataset[`${buttonType}ButtonTemplateId`]; + if (buttonTemplateID && 'content' in document.createElement('template')) { + const buttonTemplate = document.getElementById(buttonTemplateID); + if (!buttonTemplate) + throw new Error(`element with ID "${buttonTemplateID}" not found`); + return buttonTemplate.content.cloneNode(true); + } + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = buttonType === ButtonType.Add ? 'Add' : 'Delete'; + return button; + } + addItem(collectionEl) { + const currentIndex = collectionEl.dataset.currentIndex; + collectionEl.dataset.currentIndex++; + const collectionNamePattern = collectionEl.id.replace(/_/g, '(?:_|\\[|]\\[)'); + const prototype = collectionEl.dataset.prototype + .replace('__name__label__', currentIndex) + .replace(new RegExp(`(${collectionNamePattern}(?:_|]\\[))__name__`, 'g'), `$1${currentIndex}`); + const fakeEl = document.createElement('div'); + fakeEl.innerHTML = prototype; + const itemEl = fakeEl.firstElementChild; + this.connectCollection(itemEl); + this.addDeleteButton(collectionEl, itemEl); + const items = this.getItems(collectionEl); + items.length ? items[items.length - 1].insertAdjacentElement('afterend', itemEl) : collectionEl.prepend(itemEl); + } + addAddButton(collectionEl) { + const addButton = this.createButton(collectionEl, ButtonType.Add); + addButton.onclick = (e) => { + e.preventDefault(); + this.addItem(collectionEl); + }; + collectionEl.appendChild(addButton); + } + addDeleteButton(collectionEl, itemEl) { + const deleteButton = this.createButton(collectionEl, ButtonType.Delete); + deleteButton.onclick = (e) => { + e.preventDefault(); + itemEl.remove(); + }; + itemEl.appendChild(deleteButton); + } +} + +export { controller as default }; diff --git a/src/Collection/Resources/assets/jest.config.js b/src/Collection/Resources/assets/jest.config.js new file mode 100644 index 00000000000..6358ddf2772 --- /dev/null +++ b/src/Collection/Resources/assets/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../../../jest.config.js'); diff --git a/src/Collection/Resources/assets/package.json b/src/Collection/Resources/assets/package.json new file mode 100644 index 00000000000..d777a69ba74 --- /dev/null +++ b/src/Collection/Resources/assets/package.json @@ -0,0 +1,24 @@ +{ + "name": "@symfony/ux-collection", + "description": "Support for collection embedding with Symfony Form", + "license": "MIT", + "main": "dist/controller.js", + "module": "dist/controller.js", + "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/Collection/Resources/assets/src/controller.ts b/src/Collection/Resources/assets/src/controller.ts new file mode 100644 index 00000000000..9f5b918a05b --- /dev/null +++ b/src/Collection/Resources/assets/src/controller.ts @@ -0,0 +1,98 @@ +import { Controller } from '@hotwired/stimulus'; + +const DEFAULT_ITEMS_SELECTOR = ':scope > :is(div, fieldset)'; + +interface CollectionDataset extends DOMStringMap { + prototype: string; + currentIndex: string; + itemsSelector?: string; + addButtonTemplateId?: string; + disableAddButton?: string; + deleteButtonTemplateId?: string; + disableDeleteButton?: string; +} + +enum ButtonType { + Add = 'add', + Delete = 'delete', +} + +export default class extends Controller { + connect() { + this.connectCollection(this.element as HTMLElement); + } + + connectCollection(parent: HTMLElement) { + parent.querySelectorAll('[data-prototype]').forEach((el) => { + const collectionEl = el as HTMLElement; + const items = this.getItems(collectionEl); + collectionEl.dataset.currentIndex = items.length.toString(); + + this.addAddButton(collectionEl); + + this.getItems(collectionEl).forEach((itemEl) => this.addDeleteButton(collectionEl, itemEl as HTMLElement)); + }); + } + + getItems(collectionElement: HTMLElement) { + return collectionElement.querySelectorAll(collectionElement.dataset.itemsSelector || DEFAULT_ITEMS_SELECTOR); + } + + createButton(collectionEl: HTMLElement, buttonType: ButtonType): HTMLElement { + const buttonTemplateID = collectionEl.dataset[`${buttonType}ButtonTemplateId`]; + if (buttonTemplateID && 'content' in document.createElement('template')) { + // Get from template + const buttonTemplate = document.getElementById(buttonTemplateID) as HTMLTemplateElement | null; + if (!buttonTemplate) throw new Error(`element with ID "${buttonTemplateID}" not found`); + + return buttonTemplate.content.cloneNode(true) as HTMLElement; + } + + // If no template is provided, create a raw HTML button + const button = document.createElement('button') as HTMLButtonElement; + button.type = 'button'; + button.textContent = buttonType === ButtonType.Add ? 'Add' : 'Delete'; + + return button; + } + + addItem(collectionEl: HTMLElement) { + const currentIndex = (collectionEl.dataset as CollectionDataset).currentIndex; + (collectionEl.dataset.currentIndex as unknown as number)++; + + const collectionNamePattern = collectionEl.id.replace(/_/g, '(?:_|\\[|]\\[)'); + + const prototype = (collectionEl.dataset.prototype as string) // We're sure that dataset.prototype exists, because of the CSS selector used in connect() + .replace('__name__label__', currentIndex) + .replace(new RegExp(`(${collectionNamePattern}(?:_|]\\[))__name__`, 'g'), `$1${currentIndex}`); + + const fakeEl = document.createElement('div'); + fakeEl.innerHTML = prototype; + const itemEl = fakeEl.firstElementChild as HTMLElement; + + this.connectCollection(itemEl); + + this.addDeleteButton(collectionEl, itemEl); + + const items = this.getItems(collectionEl); + items.length ? items[items.length - 1].insertAdjacentElement('afterend', itemEl) : collectionEl.prepend(itemEl); + } + + addAddButton(collectionEl: HTMLElement) { + const addButton = this.createButton(collectionEl, ButtonType.Add); + addButton.onclick = (e) => { + e.preventDefault(); + this.addItem(collectionEl); + }; + collectionEl.appendChild(addButton); + } + + addDeleteButton(collectionEl: HTMLElement, itemEl: HTMLElement) { + const deleteButton = this.createButton(collectionEl, ButtonType.Delete); + deleteButton.onclick = (e) => { + e.preventDefault(); + itemEl.remove(); + }; + itemEl.appendChild(deleteButton); + } +} diff --git a/src/Collection/Resources/assets/test/controller.test.ts b/src/Collection/Resources/assets/test/controller.test.ts new file mode 100644 index 00000000000..da9f6ae16ae --- /dev/null +++ b/src/Collection/Resources/assets/test/controller.test.ts @@ -0,0 +1,40 @@ +/* + * 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 } from '@hotwired/stimulus'; +import { getByTestId } from '@testing-library/dom'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import CollectionController from '../src/controller'; + +const startStimulus = () => { + const application = Application.start(); + application.register('symfony--ux-collection--collection', CollectionController); +}; + +/* eslint-disable no-undef */ +describe('CollectionController', () => { + let container: any; + + beforeEach(() => { + container = mountDOM('
'); + }); + + afterEach(() => { + clearDOM(); + }); + + it('connects', async () => { + startStimulus(); + + // smoke test + expect(getByTestId(container, 'collection')).toHaveAttribute('data-controller', 'symfony--ux-collection--collection'); + }); +}); diff --git a/src/Collection/Tests/app/Entity/Game.php b/src/Collection/Tests/app/Entity/Game.php new file mode 100644 index 00000000000..16521cad2fd --- /dev/null +++ b/src/Collection/Tests/app/Entity/Game.php @@ -0,0 +1,40 @@ + + */ +class Game +{ + public \DateTimeImmutable $date; + /** + * @var Team[] + */ + private array $teams = []; + + public function addTeam(Team $team): void + { + $this->teams[] = $team; + $this->teams = array_values($this->teams); + + dump($this->teams); + } + + public function removeTeam(Team $team): void + { + if (false === $i = array_search($team, $this->teams, true)) { + return; + } + + unset($this->teams[$i]); + $this->teams = array_values($this->teams); + } + + public function getTeams(): array + { + return $this->teams; + } +} diff --git a/src/Collection/Tests/app/Entity/Player.php b/src/Collection/Tests/app/Entity/Player.php new file mode 100644 index 00000000000..cf390ceae7f --- /dev/null +++ b/src/Collection/Tests/app/Entity/Player.php @@ -0,0 +1,14 @@ + + */ +class Player +{ + public string $firstName; + public string $lastName; +} diff --git a/src/Collection/Tests/app/Entity/Team.php b/src/Collection/Tests/app/Entity/Team.php new file mode 100644 index 00000000000..2cc3b67ee83 --- /dev/null +++ b/src/Collection/Tests/app/Entity/Team.php @@ -0,0 +1,38 @@ + + */ +class Team +{ + public string $name; + /** + * @var Player[] + */ + private array $players = []; + + public function addPlayer(Player $player): void + { + $this->players[] = $player; + $this->players = array_values($this->players); + } + + public function removePlayer(Player $player): void + { + if (false === $i = array_search($player, $this->players, true)) { + return; + } + + unset($this->players[$i]); + $this->players = array_values($this->players); + } + + public function getPlayers(): array + { + return $this->players; + } +} diff --git a/src/Collection/Tests/app/Form/GameType.php b/src/Collection/Tests/app/Form/GameType.php new file mode 100644 index 00000000000..0ee387e6e3e --- /dev/null +++ b/src/Collection/Tests/app/Form/GameType.php @@ -0,0 +1,39 @@ + + */ +class GameType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('date', DateType::class, ['input' => 'datetime_immutable']) + ->add('teams', CollectionType::class, [ + 'entry_type' => TeamType::class, + 'allow_add' => true, + 'allow_delete' => true, + ]) + ->add('submit', SubmitType::class) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Game::class, + ]); + } +} diff --git a/src/Collection/Tests/app/Form/PlayerType.php b/src/Collection/Tests/app/Form/PlayerType.php new file mode 100644 index 00000000000..12cdf8a6b61 --- /dev/null +++ b/src/Collection/Tests/app/Form/PlayerType.php @@ -0,0 +1,31 @@ + + */ +class PlayerType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('firstName') + ->add('lastName') + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Player::class, + ]); + } +} diff --git a/src/Collection/Tests/app/Form/TeamType.php b/src/Collection/Tests/app/Form/TeamType.php new file mode 100644 index 00000000000..9c667085581 --- /dev/null +++ b/src/Collection/Tests/app/Form/TeamType.php @@ -0,0 +1,36 @@ + + */ +class TeamType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('name') + ->add('players', CollectionType::class, [ + 'entry_type' => PlayerType::class, + 'allow_add' => true, + 'allow_delete' => true, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Team::class, + ]); + } +} diff --git a/src/Collection/Tests/app/Kernel.php b/src/Collection/Tests/app/Kernel.php new file mode 100644 index 00000000000..ef9541a0dce --- /dev/null +++ b/src/Collection/Tests/app/Kernel.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App; + +use App\Form\GameType; +use Symfony\Bundle\DebugBundle\DebugBundle; +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Kernel as BaseKernel; +use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; +use Symfony\UX\Collection\CollectionBundle; +use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; +use Twig\Environment; + +/** + * @author Kévin Dunglas + */ +class Kernel extends BaseKernel +{ + use MicroKernelTrait; + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new TwigBundle(); + yield new CollectionBundle(); + yield new WebpackEncoreBundle(); + yield new WebProfilerBundle(); + yield new DebugBundle(); + } + + protected function configureContainer(ContainerConfigurator $container): void + { + $container->extension('framework', [ + 'secret' => 'ChangeMe', + 'test' => 'test' === ($_SERVER['APP_ENV'] ?? 'dev'), + 'router' => [ + 'utf8' => true, + ], + 'profiler' => [ + 'only_exceptions' => false, + ], + ]); + + $container->extension('webpack_encore', ['output_path' => 'build']); + $container->extension('web_profiler', [ + 'toolbar' => true, + 'intercept_redirects' => false, + ]); + } + + protected function configureRoutes(RoutingConfigurator $routes): void + { + $routes->import('@WebProfilerBundle/Resources/config/routing/wdt.xml')->prefix('/_wdt'); + $routes->import('@WebProfilerBundle/Resources/config/routing/profiler.xml')->prefix('/_profiler'); + + $routes->add('form', '/')->controller('kernel::form'); + } + + public function getProjectDir(): string + { + return __DIR__; + } + + public function form(Request $request, Environment $twig, FormFactoryInterface $formFactory): Response + { + $form = $formFactory->create(GameType::class); + + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + // Update array keys + $form = $formFactory->create(GameType::class, $form->getData()); + } + + return new Response( + $twig->render('form.html.twig', ['form' => $form->createView()]) + ); + } +} diff --git a/src/Collection/Tests/app/assets/app.js b/src/Collection/Tests/app/assets/app.js new file mode 100644 index 00000000000..06ec36ca101 --- /dev/null +++ b/src/Collection/Tests/app/assets/app.js @@ -0,0 +1,16 @@ +/* + * 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. + */ + +import { Application } from "@hotwired/stimulus"; +import Controller from "@symfony/ux-collection/dist/controller.js"; + +const application = Application.start(); +application.register("symfony--ux-collection--collection", Controller); + +console.log('test app initialized'); diff --git a/src/Collection/Tests/app/package.json b/src/Collection/Tests/app/package.json new file mode 100644 index 00000000000..54ecd45ed84 --- /dev/null +++ b/src/Collection/Tests/app/package.json @@ -0,0 +1,22 @@ +{ + "devDependencies": { + "@hotwired/stimulus": "^3.0.0", + "@hotwired/turbo": "^7.0.1", + "@symfony/ux-collection": "file:../../Resources/assets", + "@symfony/webpack-encore": "^0.32.0", + "core-js": "^3.0.0", + "regenerator-runtime": "^0.13.2", + "webpack-notifier": "^1.6.0" + }, + "resolutions": { + "coa": "2.0.2" + }, + "license": "MIT", + "private": true, + "scripts": { + "dev-server": "encore dev-server", + "dev": "encore dev", + "watch": "encore dev --watch", + "build": "encore production --progress" + } +} diff --git a/src/Collection/Tests/app/public/index.php b/src/Collection/Tests/app/public/index.php new file mode 100644 index 00000000000..3b0f4f6a53c --- /dev/null +++ b/src/Collection/Tests/app/public/index.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use App\Kernel; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Component\HttpFoundation\Request; + +require __DIR__.'/../../../vendor/autoload.php'; + +$app = new Kernel($_SERVER['APP_ENV'] ?? 'dev', $_SERVER['APP_DEBUG'] ?? true); + +if (\PHP_SAPI === 'cli') { + $application = new Application($app); + exit($application->run()); +} + +$request = Request::createFromGlobals(); +$response = $app->handle($request); +$response->send(); +$app->terminate($request, $response); diff --git a/src/Collection/Tests/app/templates/base.html.twig b/src/Collection/Tests/app/templates/base.html.twig new file mode 100644 index 00000000000..671dad9c562 --- /dev/null +++ b/src/Collection/Tests/app/templates/base.html.twig @@ -0,0 +1,12 @@ + + + + + Symfony UX Collection + {% block stylesheets %}{% endblock %} + {{ encore_entry_script_tags('app') }} + + + {% block body %}{% endblock %} + + diff --git a/src/Collection/Tests/app/templates/form.html.twig b/src/Collection/Tests/app/templates/form.html.twig new file mode 100644 index 00000000000..642af0c9d62 --- /dev/null +++ b/src/Collection/Tests/app/templates/form.html.twig @@ -0,0 +1,7 @@ +{% extends 'base.html.twig' %} + +{% block body %} +
+ {{ form(form) }} +
+{% endblock %} diff --git a/src/Collection/Tests/app/webpack.config.js b/src/Collection/Tests/app/webpack.config.js new file mode 100644 index 00000000000..aeb1151b30b --- /dev/null +++ b/src/Collection/Tests/app/webpack.config.js @@ -0,0 +1,23 @@ +var Encore = require('@symfony/webpack-encore'); + +if (!Encore.isRuntimeEnvironmentConfigured()) { + Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev'); +} + +Encore + .setOutputPath('public/build/') + .setPublicPath('/build') + .addEntry('app', './assets/app.js') + .splitEntryChunks() + .enableSingleRuntimeChunk() + .cleanupOutputBeforeBuild() + .enableBuildNotifications() + .enableSourceMaps(!Encore.isProduction()) + .enableVersioning(Encore.isProduction()) + .configureBabelPresetEnv((config) => { + config.useBuiltIns = 'usage'; + config.corejs = 3; + }) +; + +module.exports = Encore.getWebpackConfig(); diff --git a/src/Collection/composer.json b/src/Collection/composer.json new file mode 100644 index 00000000000..12456c85518 --- /dev/null +++ b/src/Collection/composer.json @@ -0,0 +1,63 @@ +{ + "name": "symfony/ux-collection", + "type": "symfony-bundle", + "description": "Support for collection embedding with Symfony Form", + "keywords": [ + "symfony-ux", + "form", + "collection" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\Collection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "autoload-dev": { + "psr-4": { + "App\\": "Tests/app/" + } + }, + "require": { + "php": ">=8.1", + "symfony/webpack-encore-bundle": "^1.14" + }, + "require-dev": { + "symfony/debug-bundle": "^5.4|^6.1", + "symfony/form": "^5.4|^6.1", + "symfony/framework-bundle": "^5.4|^6.1", + "symfony/panther": "^2.0", + "symfony/phpunit-bridge": "^5.4|^6.1", + "symfony/twig-bundle": "^5.4|^6.1", + "symfony/web-profiler-bundle": "^5.4|^6.1" + }, + "conflict": { + "symfony/flex": "<1.13" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev", + "config": { + "allow-plugins": { + "composer/package-versions-deprecated": true + } + } +} diff --git a/src/Collection/phpunit.xml.dist b/src/Collection/phpunit.xml.dist new file mode 100644 index 00000000000..49b9a066d39 --- /dev/null +++ b/src/Collection/phpunit.xml.dist @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + Tests + + + + + + src + + + + + + + + + + + From f0b954288747d863980e2bca06a423aed7a7de0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 19 Jul 2022 19:35:11 +0200 Subject: [PATCH 2/6] various fixes --- .../Resources/assets/dist/controller.js | 27 +++++++++++++------ .../Resources/assets/src/controller.ts | 23 ++++++++++++---- src/Collection/Tests/app/Kernel.php | 6 ++--- src/Collection/Tests/app/assets/app.js | 1 + .../{form.html.twig => basic_form.html.twig} | 2 +- .../app/templates/template_form.html.twig | 15 +++++++++++ 6 files changed, 57 insertions(+), 17 deletions(-) rename src/Collection/Tests/app/templates/{form.html.twig => basic_form.html.twig} (60%) create mode 100644 src/Collection/Tests/app/templates/template_form.html.twig diff --git a/src/Collection/Resources/assets/dist/controller.js b/src/Collection/Resources/assets/dist/controller.js index 3496ef27f08..04337f7ba0f 100644 --- a/src/Collection/Resources/assets/dist/controller.js +++ b/src/Collection/Resources/assets/dist/controller.js @@ -3,10 +3,10 @@ import { Controller } from '@hotwired/stimulus'; const DEFAULT_ITEMS_SELECTOR = ':scope > :is(div, fieldset)'; var ButtonType; (function (ButtonType) { - ButtonType["Add"] = "add"; - ButtonType["Delete"] = "delete"; + ButtonType[ButtonType["Add"] = 0] = "Add"; + ButtonType[ButtonType["Delete"] = 1] = "Delete"; })(ButtonType || (ButtonType = {})); -class controller extends Controller { +class default_1 extends Controller { connect() { this.connectCollection(this.element); } @@ -16,19 +16,24 @@ class controller extends Controller { const items = this.getItems(collectionEl); collectionEl.dataset.currentIndex = items.length.toString(); this.addAddButton(collectionEl); - this.getItems(collectionEl).forEach(itemEl => this.addDeleteButton(collectionEl, itemEl)); + this.getItems(collectionEl).forEach((itemEl) => this.addDeleteButton(collectionEl, itemEl)); }); } getItems(collectionElement) { return collectionElement.querySelectorAll(collectionElement.dataset.itemsSelector || DEFAULT_ITEMS_SELECTOR); } createButton(collectionEl, buttonType) { - const buttonTemplateID = collectionEl.dataset[`${buttonType}ButtonTemplateId`]; + var _a; + const attributeName = `${ButtonType[buttonType].toLowerCase()}ButtonTemplateId`; + const buttonTemplateID = (_a = collectionEl.dataset[attributeName]) !== null && _a !== void 0 ? _a : this[`${attributeName}Value`]; if (buttonTemplateID && 'content' in document.createElement('template')) { const buttonTemplate = document.getElementById(buttonTemplateID); if (!buttonTemplate) - throw new Error(`element with ID "${buttonTemplateID}" not found`); - return buttonTemplate.content.cloneNode(true); + throw new Error(`template with ID "${buttonTemplateID}" not found`); + const fragment = buttonTemplate.content.cloneNode(true); + if (1 !== fragment.children.length) + throw new Error('template with ID "${buttonTemplateID}" must have exactly one child'); + return fragment.firstElementChild; } const button = document.createElement('button'); button.type = 'button'; @@ -67,5 +72,11 @@ class controller extends Controller { itemEl.appendChild(deleteButton); } } +default_1.values = { + addButtonTemplateId: "", + disableAddButton: false, + deleteButtonTemplateId: "", + disableDeleteButton: false, +}; -export { controller as default }; +export { default_1 as default }; diff --git a/src/Collection/Resources/assets/src/controller.ts b/src/Collection/Resources/assets/src/controller.ts index 9f5b918a05b..a753ba7ec37 100644 --- a/src/Collection/Resources/assets/src/controller.ts +++ b/src/Collection/Resources/assets/src/controller.ts @@ -13,11 +13,18 @@ interface CollectionDataset extends DOMStringMap { } enum ButtonType { - Add = 'add', - Delete = 'delete', + Add, + Delete, } export default class extends Controller { + static values = { + addButtonTemplateId: "", + disableAddButton: false, + deleteButtonTemplateId: "", + disableDeleteButton: false, + }; + connect() { this.connectCollection(this.element as HTMLElement); } @@ -39,13 +46,19 @@ export default class extends Controller { } createButton(collectionEl: HTMLElement, buttonType: ButtonType): HTMLElement { - const buttonTemplateID = collectionEl.dataset[`${buttonType}ButtonTemplateId`]; + const attributeName = `${ButtonType[buttonType].toLowerCase()}ButtonTemplateId`; + const buttonTemplateID = collectionEl.dataset[attributeName] ?? (this as any)[`${attributeName}Value`]; if (buttonTemplateID && 'content' in document.createElement('template')) { // Get from template const buttonTemplate = document.getElementById(buttonTemplateID) as HTMLTemplateElement | null; - if (!buttonTemplate) throw new Error(`element with ID "${buttonTemplateID}" not found`); + if (!buttonTemplate) + throw new Error(`template with ID "${buttonTemplateID}" not found`); + + const fragment = (buttonTemplate.content.cloneNode(true) as DocumentFragment); + if (1 !== fragment.children.length) + throw new Error('template with ID "${buttonTemplateID}" must have exactly one child'); - return buttonTemplate.content.cloneNode(true) as HTMLElement; + return fragment.firstElementChild as HTMLElement; } // If no template is provided, create a raw HTML button diff --git a/src/Collection/Tests/app/Kernel.php b/src/Collection/Tests/app/Kernel.php index ef9541a0dce..2110e074312 100644 --- a/src/Collection/Tests/app/Kernel.php +++ b/src/Collection/Tests/app/Kernel.php @@ -69,7 +69,7 @@ protected function configureRoutes(RoutingConfigurator $routes): void $routes->import('@WebProfilerBundle/Resources/config/routing/wdt.xml')->prefix('/_wdt'); $routes->import('@WebProfilerBundle/Resources/config/routing/profiler.xml')->prefix('/_profiler'); - $routes->add('form', '/')->controller('kernel::form'); + $routes->add('form', '/{type<^basic|template$>?basic}')->controller('kernel::form'); } public function getProjectDir(): string @@ -77,7 +77,7 @@ public function getProjectDir(): string return __DIR__; } - public function form(Request $request, Environment $twig, FormFactoryInterface $formFactory): Response + public function form(Request $request, Environment $twig, FormFactoryInterface $formFactory, string $type): Response { $form = $formFactory->create(GameType::class); @@ -88,7 +88,7 @@ public function form(Request $request, Environment $twig, FormFactoryInterface $ } return new Response( - $twig->render('form.html.twig', ['form' => $form->createView()]) + $twig->render("{$type}_form.html.twig", ['form' => $form->createView()]) ); } } diff --git a/src/Collection/Tests/app/assets/app.js b/src/Collection/Tests/app/assets/app.js index 06ec36ca101..8f05c84fb6b 100644 --- a/src/Collection/Tests/app/assets/app.js +++ b/src/Collection/Tests/app/assets/app.js @@ -12,5 +12,6 @@ import Controller from "@symfony/ux-collection/dist/controller.js"; const application = Application.start(); application.register("symfony--ux-collection--collection", Controller); +application.register("collection", Controller); console.log('test app initialized'); diff --git a/src/Collection/Tests/app/templates/form.html.twig b/src/Collection/Tests/app/templates/basic_form.html.twig similarity index 60% rename from src/Collection/Tests/app/templates/form.html.twig rename to src/Collection/Tests/app/templates/basic_form.html.twig index 642af0c9d62..81ebd4d1577 100644 --- a/src/Collection/Tests/app/templates/form.html.twig +++ b/src/Collection/Tests/app/templates/basic_form.html.twig @@ -1,7 +1,7 @@ {% extends 'base.html.twig' %} {% block body %} -
+
{{ form(form) }}
{% endblock %} diff --git a/src/Collection/Tests/app/templates/template_form.html.twig b/src/Collection/Tests/app/templates/template_form.html.twig new file mode 100644 index 00000000000..77176461836 --- /dev/null +++ b/src/Collection/Tests/app/templates/template_form.html.twig @@ -0,0 +1,15 @@ +{% extends 'base.html.twig' %} + +{% block body %} + + + + +
+ {{ form(form) }} +
+{% endblock %} From 183c400ad361f13a0d8cf67ae554cb60f59fe332 Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Tue, 19 Jul 2022 21:27:33 +0200 Subject: [PATCH 3/6] Implement collection type as form extension --- .../CollectionExtension.php | 37 +++++ .../Form/CollectionTypeExtension.php | 111 +++++++++++++ .../Resources/assets/dist/controller.js | 120 +++++++------- .../Resources/assets/src/controller.ts | 151 ++++++++---------- src/Collection/Tests/app/Entity/Game.php | 2 - src/Collection/Tests/app/Form/GameType.php | 7 + src/Collection/Tests/app/Form/TeamType.php | 6 + src/Collection/Tests/app/Kernel.php | 21 ++- .../Tests/app/templates/basic_form.html.twig | 2 - .../app/templates/template_form.html.twig | 15 -- 10 files changed, 304 insertions(+), 168 deletions(-) create mode 100644 src/Collection/DependencyInjection/CollectionExtension.php create mode 100644 src/Collection/Form/CollectionTypeExtension.php delete mode 100644 src/Collection/Tests/app/templates/template_form.html.twig diff --git a/src/Collection/DependencyInjection/CollectionExtension.php b/src/Collection/DependencyInjection/CollectionExtension.php new file mode 100644 index 00000000000..555f42a73e4 --- /dev/null +++ b/src/Collection/DependencyInjection/CollectionExtension.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\Collection\DependencyInjection; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\UX\Collection\Form\CollectionTypeExtension; + +/** + * @author Ryan Weaver + * + * @experimental + */ +final class CollectionExtension extends Extension +{ + public function load(array $configs, ContainerBuilder $container) + { + $this->registerBasicServices($container); + } + + private function registerBasicServices(ContainerBuilder $container): void + { + $container + ->register('ux.collection.collection_extension', CollectionTypeExtension::class) + ->addTag('form.type_extension') + ; + } +} diff --git a/src/Collection/Form/CollectionTypeExtension.php b/src/Collection/Form/CollectionTypeExtension.php new file mode 100644 index 00000000000..1d0aaee39d3 --- /dev/null +++ b/src/Collection/Form/CollectionTypeExtension.php @@ -0,0 +1,111 @@ +getAttribute('prototype'); + + if (!$prototype) { + return; + } + + // TODO add button only if `delete_type` is defined and set `delete_type` default to null? + if ($options['allow_delete']) { + // add delete button to prototype + // TODO add toolbar here to allow extension add other buttons + $prototype->add('deleteButton', $options['delete_type'], $options['delete_options']); + } + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + /** @var FormInterface|null $prototype */ + $prototype = $form->getConfig()->getAttribute('prototype'); + + if (!$prototype) { + return; + } + + if ($options['allow_delete']) { + // add delete button to rendered elements from the Collection ResizeListener + foreach ($form as $child) { + $child->add('deleteButton', $options['delete_type'], $options['delete_options']); + } + } + + // TODO add button only if `add_type` is defined and set `add_type` default to null? + if ($options['allow_add']) { + // TODO add toolbar here to allow extension add other buttons + $form->add('addButton', $options['add_type'], $options['add_options']); + } + } + + public function configureOptions(OptionsResolver $resolver) + { + $attrNormalizer = function (Options $options, $value) { + if (!isset($value['data-controller'])) { + // TODO default be `symfony--ux-collection--collection` or `collection`? + $value['data-controller'] = 'symfony--ux-collection--collection'; + } + + $value['data-' . $value['data-controller'] . '-prototype-name-value'] = $options['prototype_name']; + + return $value; + }; + + $resolver->setDefaults([ + 'add_type' => ButtonType::class, + 'add_options' => [], + 'delete_type' => ButtonType::class, + 'delete_options' => [], + ]); + + $addOptionsNormalizer = function (Options $options, $value) { + $value['block_name'] = 'add_button'; + $value['attr'] = \array_merge([ + '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-action' => $options['attr']['data-controller'] . '#delete', + ], $value['attr'] ?? []); + + return $value; + }; + + $entryOptionsNormalizer = function (Options $options, $value) { + $value['row_attr']['data-' . $options['attr']['data-controller'] . '-target'] = 'entry'; + + return $value; + }; + + $resolver->setNormalizer('attr', $attrNormalizer); + $resolver->setNormalizer('add_options', $addOptionsNormalizer); + $resolver->setNormalizer('delete_options', $deleteOptionsNormalizer); + $resolver->addNormalizer('entry_options', $entryOptionsNormalizer); + } +} diff --git a/src/Collection/Resources/assets/dist/controller.js b/src/Collection/Resources/assets/dist/controller.js index 04337f7ba0f..63117bcdbe9 100644 --- a/src/Collection/Resources/assets/dist/controller.js +++ b/src/Collection/Resources/assets/dist/controller.js @@ -1,82 +1,70 @@ import { Controller } from '@hotwired/stimulus'; -const DEFAULT_ITEMS_SELECTOR = ':scope > :is(div, fieldset)'; -var ButtonType; -(function (ButtonType) { - ButtonType[ButtonType["Add"] = 0] = "Add"; - ButtonType[ButtonType["Delete"] = 1] = "Delete"; -})(ButtonType || (ButtonType = {})); class default_1 extends Controller { + constructor() { + super(...arguments); + this.index = 0; + this.controllerName = 'collection'; + } connect() { - this.connectCollection(this.element); + this.controllerName = this.context.scope.identifier; + this._dispatchEvent('collection:connect'); } - connectCollection(parent) { - parent.querySelectorAll('[data-prototype]').forEach((el) => { - const collectionEl = el; - const items = this.getItems(collectionEl); - collectionEl.dataset.currentIndex = items.length.toString(); - this.addAddButton(collectionEl); - this.getItems(collectionEl).forEach((itemEl) => this.addDeleteButton(collectionEl, itemEl)); + 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 collectionNamePattern = this.element.id.replace(/_/g, '(?:_|\\[|]\\[)'); + const newEntry = this._textToNode(prototypeHTML + .replace(this.prototypeNameValue + 'label__', this.index.toString()) + .replace(new RegExp(`(${collectionNamePattern}(?:_|]\\[))${this.prototypeNameValue}`, 'g'), `$1${this.index.toString()}`)); + this._dispatchEvent('collection:pre-add', { + entry: newEntry, + index: this.index, }); - } - getItems(collectionElement) { - return collectionElement.querySelectorAll(collectionElement.dataset.itemsSelector || DEFAULT_ITEMS_SELECTOR); - } - createButton(collectionEl, buttonType) { - var _a; - const attributeName = `${ButtonType[buttonType].toLowerCase()}ButtonTemplateId`; - const buttonTemplateID = (_a = collectionEl.dataset[attributeName]) !== null && _a !== void 0 ? _a : this[`${attributeName}Value`]; - if (buttonTemplateID && 'content' in document.createElement('template')) { - const buttonTemplate = document.getElementById(buttonTemplateID); - if (!buttonTemplate) - throw new Error(`template with ID "${buttonTemplateID}" not found`); - const fragment = buttonTemplate.content.cloneNode(true); - if (1 !== fragment.children.length) - throw new Error('template with ID "${buttonTemplateID}" must have exactly one child'); - return fragment.firstElementChild; + const entries = []; + this.element.querySelectorAll(this.itemSelectorValue + ? this.itemSelectorValue.replace('%controllerName%', this.controllerName) + : ':scope > [data-' + this.controllerName + '-target="entry"]:not([data-controller] > *)').forEach(entry => { + entries.push(entry); + }); + if (entries.length > 0) { + entries[entries.length - 1].after(newEntry); } - const button = document.createElement('button'); - button.type = 'button'; - button.textContent = buttonType === ButtonType.Add ? 'Add' : 'Delete'; - return button; + else { + this.element.prepend(newEntry); + } + this._dispatchEvent('collection:add', { + entry: newEntry, + index: this.index, + }); + this.index++; } - addItem(collectionEl) { - const currentIndex = collectionEl.dataset.currentIndex; - collectionEl.dataset.currentIndex++; - const collectionNamePattern = collectionEl.id.replace(/_/g, '(?:_|\\[|]\\[)'); - const prototype = collectionEl.dataset.prototype - .replace('__name__label__', currentIndex) - .replace(new RegExp(`(${collectionNamePattern}(?:_|]\\[))__name__`, 'g'), `$1${currentIndex}`); - const fakeEl = document.createElement('div'); - fakeEl.innerHTML = prototype; - const itemEl = fakeEl.firstElementChild; - this.connectCollection(itemEl); - this.addDeleteButton(collectionEl, itemEl); - const items = this.getItems(collectionEl); - items.length ? items[items.length - 1].insertAdjacentElement('afterend', itemEl) : collectionEl.prepend(itemEl); + delete(event) { + const clickTarget = event.target; + const entry = clickTarget.closest('[data-' + this.controllerName + '-target="entry"]'); + this._dispatchEvent('collection:pre-delete', { + entry: entry, + }); + entry.remove(); + this._dispatchEvent('collection:delete', { + entry: entry, + }); } - addAddButton(collectionEl) { - const addButton = this.createButton(collectionEl, ButtonType.Add); - addButton.onclick = (e) => { - e.preventDefault(); - this.addItem(collectionEl); - }; - collectionEl.appendChild(addButton); + _textToNode(text) { + const template = document.createElement('template'); + text = text.trim(); + template.innerHTML = text; + return template.content.firstChild; } - addDeleteButton(collectionEl, itemEl) { - const deleteButton = this.createButton(collectionEl, ButtonType.Delete); - deleteButton.onclick = (e) => { - e.preventDefault(); - itemEl.remove(); - }; - itemEl.appendChild(deleteButton); + _dispatchEvent(name, payload = {}) { + this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); } } default_1.values = { - addButtonTemplateId: "", - disableAddButton: false, - deleteButtonTemplateId: "", - disableDeleteButton: false, + prototypeName: String, + itemSelector: String, }; export { default_1 as default }; diff --git a/src/Collection/Resources/assets/src/controller.ts b/src/Collection/Resources/assets/src/controller.ts index a753ba7ec37..6a579923768 100644 --- a/src/Collection/Resources/assets/src/controller.ts +++ b/src/Collection/Resources/assets/src/controller.ts @@ -1,111 +1,98 @@ import { Controller } from '@hotwired/stimulus'; -const DEFAULT_ITEMS_SELECTOR = ':scope > :is(div, fieldset)'; - -interface CollectionDataset extends DOMStringMap { - prototype: string; - currentIndex: string; - itemsSelector?: string; - addButtonTemplateId?: string; - disableAddButton?: string; - deleteButtonTemplateId?: string; - disableDeleteButton?: string; -} - -enum ButtonType { - Add, - Delete, -} - export default class extends Controller { static values = { - addButtonTemplateId: "", - disableAddButton: false, - deleteButtonTemplateId: "", - disableDeleteButton: false, - }; - - connect() { - this.connectCollection(this.element as HTMLElement); + prototypeName: String, + itemSelector: String, } - connectCollection(parent: HTMLElement) { - parent.querySelectorAll('[data-prototype]').forEach((el) => { - const collectionEl = el as HTMLElement; - const items = this.getItems(collectionEl); - collectionEl.dataset.currentIndex = items.length.toString(); + // @ts-ignore + declare readonly element: HTMLElement; + declare readonly prototypeNameValue: string; + declare readonly itemSelectorValue: string; - this.addAddButton(collectionEl); + index = 0; + controllerName = 'collection'; - this.getItems(collectionEl).forEach((itemEl) => this.addDeleteButton(collectionEl, itemEl as HTMLElement)); - }); + connect() { + this.controllerName = this.context.scope.identifier; + this._dispatchEvent('collection:connect'); } - getItems(collectionElement: HTMLElement) { - return collectionElement.querySelectorAll(collectionElement.dataset.itemsSelector || DEFAULT_ITEMS_SELECTOR); - } + add() { + const prototypeHTML = this.element.dataset.prototype; - createButton(collectionEl: HTMLElement, buttonType: ButtonType): HTMLElement { - const attributeName = `${ButtonType[buttonType].toLowerCase()}ButtonTemplateId`; - const buttonTemplateID = collectionEl.dataset[attributeName] ?? (this as any)[`${attributeName}Value`]; - if (buttonTemplateID && 'content' in document.createElement('template')) { - // Get from template - const buttonTemplate = document.getElementById(buttonTemplateID) as HTMLTemplateElement | null; - if (!buttonTemplate) - throw new Error(`template with ID "${buttonTemplateID}" not found`); + if (!prototypeHTML) { + throw new Error( + 'A "data-prototype" attribute was expected on data-controller="' + this.controllerName + '" element.' + ); + } - const fragment = (buttonTemplate.content.cloneNode(true) as DocumentFragment); - if (1 !== fragment.children.length) - throw new Error('template with ID "${buttonTemplateID}" must have exactly one child'); + // replace only first appearance of prototypeNameValue to support nested blocks with same prototypeName + const collectionNamePattern = this.element.id.replace(/_/g, '(?:_|\\[|]\\[)'); + const newEntry = this._textToNode( + prototypeHTML + .replace(this.prototypeNameValue + 'label__', this.index.toString()) + .replace( + new RegExp(`(${collectionNamePattern}(?:_|]\\[))${this.prototypeNameValue}`, 'g'), + `$1${this.index.toString()}` + ) + ); + + this._dispatchEvent('collection:pre-add', { + entry: newEntry, + index: this.index, + }); - return fragment.firstElementChild as HTMLElement; + const entries: Element[] = []; + this.element.querySelectorAll( + this.itemSelectorValue + ? this.itemSelectorValue.replace('%controllerName%', this.controllerName) + : ':scope > [data-' + this.controllerName + '-target="entry"]:not([data-controller] > *)' + ).forEach(entry => { + entries.push(entry); + }); + + if (entries.length > 0) { + entries[entries.length - 1].after(newEntry); + } else { + this.element.prepend(newEntry); } - // If no template is provided, create a raw HTML button - const button = document.createElement('button') as HTMLButtonElement; - button.type = 'button'; - button.textContent = buttonType === ButtonType.Add ? 'Add' : 'Delete'; + this._dispatchEvent('collection:add', { + entry: newEntry, + index: this.index, + }); - return button; + this.index++; } - addItem(collectionEl: HTMLElement) { - const currentIndex = (collectionEl.dataset as CollectionDataset).currentIndex; - (collectionEl.dataset.currentIndex as unknown as number)++; + delete(event: MouseEvent) { + const clickTarget = event.target as HTMLButtonElement; - const collectionNamePattern = collectionEl.id.replace(/_/g, '(?:_|\\[|]\\[)'); + const entry = clickTarget.closest('[data-' + this.controllerName + '-target="entry"]') as HTMLElement; - const prototype = (collectionEl.dataset.prototype as string) // We're sure that dataset.prototype exists, because of the CSS selector used in connect() - .replace('__name__label__', currentIndex) - .replace(new RegExp(`(${collectionNamePattern}(?:_|]\\[))__name__`, 'g'), `$1${currentIndex}`); + this._dispatchEvent('collection:pre-delete', { + entry: entry, + }); - const fakeEl = document.createElement('div'); - fakeEl.innerHTML = prototype; - const itemEl = fakeEl.firstElementChild as HTMLElement; + entry.remove(); - this.connectCollection(itemEl); + this._dispatchEvent('collection:delete', { + entry: entry, + }); + } - this.addDeleteButton(collectionEl, itemEl); + _textToNode(text: string): HTMLElement { + const template = document.createElement('template'); + text = text.trim(); - const items = this.getItems(collectionEl); - items.length ? items[items.length - 1].insertAdjacentElement('afterend', itemEl) : collectionEl.prepend(itemEl); - } + template.innerHTML = text; - addAddButton(collectionEl: HTMLElement) { - const addButton = this.createButton(collectionEl, ButtonType.Add); - addButton.onclick = (e) => { - e.preventDefault(); - this.addItem(collectionEl); - }; - collectionEl.appendChild(addButton); + return template.content.firstChild as HTMLElement; } - addDeleteButton(collectionEl: HTMLElement, itemEl: HTMLElement) { - const deleteButton = this.createButton(collectionEl, ButtonType.Delete); - deleteButton.onclick = (e) => { - e.preventDefault(); - itemEl.remove(); - }; - itemEl.appendChild(deleteButton); + _dispatchEvent(name: string, payload: {} = {}) { + this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); } } diff --git a/src/Collection/Tests/app/Entity/Game.php b/src/Collection/Tests/app/Entity/Game.php index 16521cad2fd..322ff17a804 100644 --- a/src/Collection/Tests/app/Entity/Game.php +++ b/src/Collection/Tests/app/Entity/Game.php @@ -19,8 +19,6 @@ public function addTeam(Team $team): void { $this->teams[] = $team; $this->teams = array_values($this->teams); - - dump($this->teams); } public function removeTeam(Team $team): void diff --git a/src/Collection/Tests/app/Form/GameType.php b/src/Collection/Tests/app/Form/GameType.php index 0ee387e6e3e..4dabecfa727 100644 --- a/src/Collection/Tests/app/Form/GameType.php +++ b/src/Collection/Tests/app/Form/GameType.php @@ -6,6 +6,7 @@ use App\Entity\Game; 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\DateType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; @@ -25,6 +26,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'entry_type' => TeamType::class, 'allow_add' => true, 'allow_delete' => true, + 'add_options' => [ + 'label' => 'Add Team', + ], + 'delete_options' => [ + 'label' => 'Remove Team', + ], ]) ->add('submit', SubmitType::class) ; diff --git a/src/Collection/Tests/app/Form/TeamType.php b/src/Collection/Tests/app/Form/TeamType.php index 9c667085581..b0f05f247b9 100644 --- a/src/Collection/Tests/app/Form/TeamType.php +++ b/src/Collection/Tests/app/Form/TeamType.php @@ -23,6 +23,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'entry_type' => PlayerType::class, 'allow_add' => true, 'allow_delete' => true, + 'add_options' => [ + 'label' => 'Add Player', + ], + 'delete_options' => [ + 'label' => 'Remove Player', + ], ]) ; } diff --git a/src/Collection/Tests/app/Kernel.php b/src/Collection/Tests/app/Kernel.php index 2110e074312..6aaeeb728b0 100644 --- a/src/Collection/Tests/app/Kernel.php +++ b/src/Collection/Tests/app/Kernel.php @@ -11,6 +11,9 @@ namespace App; +use App\Entity\Game; +use App\Entity\Player; +use App\Entity\Team; use App\Form\GameType; use Symfony\Bundle\DebugBundle\DebugBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; @@ -79,7 +82,23 @@ public function getProjectDir(): string public function form(Request $request, Environment $twig, FormFactoryInterface $formFactory, string $type): Response { - $form = $formFactory->create(GameType::class); + $game = new Game(); + $team1 = new Team(); + $team1->name = 'Symfony UX Team'; + $player1 = new Player(); + $player1->firstName = 'Player'; + $player1->lastName = 'A1'; + $team1->addPlayer($player1); + $player2 = new Player(); + $player2->firstName = 'Player'; + $player2->lastName = 'A1'; + $team1->addPlayer($player2); + $team2 = new Team(); + $team2->name = 'Symfony Core Team'; + $game->addTeam($team1); + $game->addTeam($team2); + + $form = $formFactory->create(GameType::class, $game); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { diff --git a/src/Collection/Tests/app/templates/basic_form.html.twig b/src/Collection/Tests/app/templates/basic_form.html.twig index 81ebd4d1577..55ebd748220 100644 --- a/src/Collection/Tests/app/templates/basic_form.html.twig +++ b/src/Collection/Tests/app/templates/basic_form.html.twig @@ -1,7 +1,5 @@ {% extends 'base.html.twig' %} {% block body %} -
{{ form(form) }} -
{% endblock %} diff --git a/src/Collection/Tests/app/templates/template_form.html.twig b/src/Collection/Tests/app/templates/template_form.html.twig deleted file mode 100644 index 77176461836..00000000000 --- a/src/Collection/Tests/app/templates/template_form.html.twig +++ /dev/null @@ -1,15 +0,0 @@ -{% extends 'base.html.twig' %} - -{% block body %} - - - - -
- {{ form(form) }} -
-{% endblock %} From e70a60cb040a235ed34faf25a6bf0c06f64909bd Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Tue, 19 Jul 2022 22:52:51 +0200 Subject: [PATCH 4/6] Remove setting the block_prefix --- src/Collection/Form/CollectionTypeExtension.php | 12 ++++++++++-- src/Collection/Tests/app/Kernel.php | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Collection/Form/CollectionTypeExtension.php b/src/Collection/Form/CollectionTypeExtension.php index 1d0aaee39d3..96b9c25b7ae 100644 --- a/src/Collection/Form/CollectionTypeExtension.php +++ b/src/Collection/Form/CollectionTypeExtension.php @@ -59,6 +59,16 @@ public function buildView(FormView $view, FormInterface $form, array $options): } } + public function finishView(FormView $view, FormInterface $form, array $options): void + { + if (!$form->has('addButton')) { + return; + } + + $addButton = $form->get('addButton'); + $form->add('addButton', $options['add_type'], $options['add_options']); + } + public function configureOptions(OptionsResolver $resolver) { $attrNormalizer = function (Options $options, $value) { @@ -80,7 +90,6 @@ public function configureOptions(OptionsResolver $resolver) ]); $addOptionsNormalizer = function (Options $options, $value) { - $value['block_name'] = 'add_button'; $value['attr'] = \array_merge([ 'data-action' => $options['attr']['data-controller'] . '#add', ], $value['attr'] ?? []); @@ -89,7 +98,6 @@ public function configureOptions(OptionsResolver $resolver) }; $deleteOptionsNormalizer = function (Options $options, $value) { - $value['block_name'] = 'delete_button'; $value['attr'] = \array_merge([ 'data-action' => $options['attr']['data-controller'] . '#delete', ], $value['attr'] ?? []); diff --git a/src/Collection/Tests/app/Kernel.php b/src/Collection/Tests/app/Kernel.php index 6aaeeb728b0..eddff3c16e5 100644 --- a/src/Collection/Tests/app/Kernel.php +++ b/src/Collection/Tests/app/Kernel.php @@ -91,7 +91,7 @@ public function form(Request $request, Environment $twig, FormFactoryInterface $ $team1->addPlayer($player1); $player2 = new Player(); $player2->firstName = 'Player'; - $player2->lastName = 'A1'; + $player2->lastName = 'A2'; $team1->addPlayer($player2); $team2 = new Team(); $team2->name = 'Symfony Core Team'; From dd09371459d46f0fc2aab95ee3174a24e8d1ee3a Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Tue, 19 Jul 2022 23:01:45 +0200 Subject: [PATCH 5/6] Add basic form theme for testing --- .../Form/CollectionTypeExtension.php | 4 +- .../Tests/app/templates/basic_form.html.twig | 5 ++- .../app/templates/example_theme.html.twig | 37 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 src/Collection/Tests/app/templates/example_theme.html.twig diff --git a/src/Collection/Form/CollectionTypeExtension.php b/src/Collection/Form/CollectionTypeExtension.php index 96b9c25b7ae..1139f3bf508 100644 --- a/src/Collection/Form/CollectionTypeExtension.php +++ b/src/Collection/Form/CollectionTypeExtension.php @@ -83,9 +83,9 @@ public function configureOptions(OptionsResolver $resolver) }; $resolver->setDefaults([ - 'add_type' => ButtonType::class, + 'add_type' => ButtonType::class, // TODO add AddButtonType for easier theming and extending 'add_options' => [], - 'delete_type' => ButtonType::class, + 'delete_type' => ButtonType::class, // TODO add DeleteButtonType for easier theming and extending 'delete_options' => [], ]); diff --git a/src/Collection/Tests/app/templates/basic_form.html.twig b/src/Collection/Tests/app/templates/basic_form.html.twig index 55ebd748220..002d63a5037 100644 --- a/src/Collection/Tests/app/templates/basic_form.html.twig +++ b/src/Collection/Tests/app/templates/basic_form.html.twig @@ -1,5 +1,8 @@ {% extends 'base.html.twig' %} {% block body %} - {{ form(form) }} +
+ {% form_theme form 'example_theme.html.twig' %} + {{ form(form) }} +
{% endblock %} diff --git a/src/Collection/Tests/app/templates/example_theme.html.twig b/src/Collection/Tests/app/templates/example_theme.html.twig new file mode 100644 index 00000000000..b7b5c85c0e2 --- /dev/null +++ b/src/Collection/Tests/app/templates/example_theme.html.twig @@ -0,0 +1,37 @@ +{% use 'form_div_layout.html.twig' %} + +{%- block team_row -%} + {% set row_attr = row_attr|merge({ + style: 'margin-bottom: 16px; background: #efefef; border: 1px solid #ccc; padding: 16px; border-radius: 4px;' + }) %} + + {{ block('collection_row') }} +{%- endblock -%} + +{%- block player_row -%} + {% set row_attr = row_attr|merge({ + style: 'margin-bottom: 16px; background: white; border: 1px solid #ccc; padding: 16px; border-radius: 4px;' + }) %} + + {{ block('collection_row') }} +{%- endblock -%} + +{%- block button_widget -%} + {% set attr = attr|merge({ + style: 'background: white; border: 1px solid #ccc; padding: 8px 16px; border-radius: 4px;' + }) %} + +
+ {{ parent() }} +
+{%- endblock -%} + +{%- block form_label -%} + {% set label_attr = label_attr|merge({ + style: 'font-weight: bold; font-family: sans-serif; font-size: 14px; cursor: pointer;' + }) %} + +
+ {{ parent() }} +
+{%- endblock -%} From 59e23b9a9a4201a4e4b2e766787f6daf365d53ae Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Tue, 19 Jul 2022 23:31:37 +0200 Subject: [PATCH 6/6] Update basic theme --- .../app/templates/example_theme.html.twig | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/src/Collection/Tests/app/templates/example_theme.html.twig b/src/Collection/Tests/app/templates/example_theme.html.twig index b7b5c85c0e2..913d44bfbe8 100644 --- a/src/Collection/Tests/app/templates/example_theme.html.twig +++ b/src/Collection/Tests/app/templates/example_theme.html.twig @@ -10,22 +10,62 @@ {%- block player_row -%} {% set row_attr = row_attr|merge({ - style: 'margin-bottom: 16px; background: white; border: 1px solid #ccc; padding: 16px; border-radius: 4px;' + style: 'margin-bottom: 16px; background: white; border: 1px solid #ccc; padding: 16px; border-radius: 4px;' }) %} {{ block('collection_row') }} {%- endblock -%} +{%- block button_row -%} + {% set row_attr = row_attr|merge({ + style: + form.vars.block_prefixes|last ends with 'addButton' + ? 'margin-top: 8px; display: flex; justify-content: start; flex-wrap: wrap;' + : form.vars.block_prefixes|last ends with 'deleteButton' + ? 'margin-top: 8px; display: flex; justify-content: end; flex-wrap: wrap;' + : '' + }) %} + + {{ parent() }} +{%- endblock -%} + +{%- block form_widget_simple -%} + {% set attr = attr|merge({ + style: type|default('text') == 'text' + ? 'width: 100%; padding: 4px 8px; box-sizing: border-box; border-radius: 3px; border: 1px solid gray;' + : '' + }) %} + + {{ parent() }} +{%- endblock -%} + {%- block button_widget -%} {% set attr = attr|merge({ - style: 'background: white; border: 1px solid #ccc; padding: 8px 16px; border-radius: 4px;' + style: + form.vars.block_prefixes|last ends with 'addButton' + ? 'background: white; border: 1px solid gray; color: black; padding: 8px 16px; border-radius: 4px; cursor: pointer;' + : form.vars.block_prefixes|last ends with 'deleteButton' + ? 'background: #F43F5E; border: 1px solid #F43F5E; color: white; padding: 4px 8px; font-size: 12px; border-radius: 4px; cursor: pointer;' + : 'background: #4338CA; border: 1px solid #4338CA; color: white; padding: 8px 16px; border-radius: 4px; cursor: pointer;' }) %} -
- {{ parent() }} + {{ parent() }} +{%- endblock -%} + +{%- block submit_row -%} +
+ {{ block('button_row') }}
{%- endblock -%} +{%- block submit_widget -%} + {% set attr = attr|merge({ + style: 'marign-top: 16px; background: #1D4ED8; border: 1px solid #1D4ED8; color: white; padding: 8px 16px; border-radius: 4px; cursor: pointer;' + }) %} + + {{ parent() }} +{%- endblock -%} + {%- block form_label -%} {% set label_attr = label_attr|merge({ style: 'font-weight: bold; font-family: sans-serif; font-size: 14px; cursor: pointer;'