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/5] 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/5] 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 ddb05ed85defaf38c3af48c84a3b27d0dee7a3b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Wed, 20 Jul 2022 16:50:57 +0200 Subject: [PATCH 3/5] CS --- src/Collection/Resources/assets/src/controller.ts | 9 ++++----- src/Collection/composer.json | 7 +------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/Collection/Resources/assets/src/controller.ts b/src/Collection/Resources/assets/src/controller.ts index a753ba7ec37..ff2bac60f5c 100644 --- a/src/Collection/Resources/assets/src/controller.ts +++ b/src/Collection/Resources/assets/src/controller.ts @@ -19,9 +19,9 @@ enum ButtonType { export default class extends Controller { static values = { - addButtonTemplateId: "", + addButtonTemplateId: '', disableAddButton: false, - deleteButtonTemplateId: "", + deleteButtonTemplateId: '', disableDeleteButton: false, }; @@ -51,10 +51,9 @@ export default class extends Controller { 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 (!buttonTemplate) throw new Error(`template with ID "${buttonTemplateID}" not found`); - const fragment = (buttonTemplate.content.cloneNode(true) as DocumentFragment); + 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'); diff --git a/src/Collection/composer.json b/src/Collection/composer.json index 12456c85518..3fb68419d45 100644 --- a/src/Collection/composer.json +++ b/src/Collection/composer.json @@ -54,10 +54,5 @@ "url": "https://github.com/symfony/ux" } }, - "minimum-stability": "dev", - "config": { - "allow-plugins": { - "composer/package-versions-deprecated": true - } - } + "minimum-stability": "dev" } From 47aca25aa4007d7ec5a13e73c4f723ead4cddfea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Wed, 20 Jul 2022 17:47:58 +0200 Subject: [PATCH 4/5] more examples --- src/Autocomplete/assets/dist/controller.js | 38 ++++----- src/Collection/Tests/app/Form/TeamType.php | 2 +- src/Collection/Tests/app/assets/app.js | 9 +- .../Tests/app/templates/basic_form.html.twig | 8 +- .../app/templates/template_form.html.twig | 22 +++-- .../assets/dist/live_controller.js | 85 ++++++++++--------- 6 files changed, 91 insertions(+), 73 deletions(-) diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js index a559b224749..23b4e6515ba 100644 --- a/src/Autocomplete/assets/dist/controller.js +++ b/src/Autocomplete/assets/dist/controller.js @@ -1,25 +1,25 @@ import { Controller } from '@hotwired/stimulus'; import TomSelect from 'tom-select'; -/****************************************************************************** -Copyright (c) Microsoft Corporation. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. -***************************************************************************** */ - -function __classPrivateFieldGet(receiver, state, kind, f) { - if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); - if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); - return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); +/****************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ + +function __classPrivateFieldGet(receiver, state, kind, f) { + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); + return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); } var _instances, _getCommonConfig, _createAutocomplete, _createAutocompleteWithHtmlContents, _createAutocompleteWithRemoteData, _stripTags, _mergeObjects, _createTomSelect, _dispatchEvent; diff --git a/src/Collection/Tests/app/Form/TeamType.php b/src/Collection/Tests/app/Form/TeamType.php index 9c667085581..2accf5e8d4d 100644 --- a/src/Collection/Tests/app/Form/TeamType.php +++ b/src/Collection/Tests/app/Form/TeamType.php @@ -18,7 +18,7 @@ class TeamType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options): void { $builder - ->add('name') + ->add('name', null, ['attr' => ['data-controller' => 'test']]) ->add('players', CollectionType::class, [ 'entry_type' => PlayerType::class, 'allow_add' => true, diff --git a/src/Collection/Tests/app/assets/app.js b/src/Collection/Tests/app/assets/app.js index 8f05c84fb6b..6b4bc1dbe0f 100644 --- a/src/Collection/Tests/app/assets/app.js +++ b/src/Collection/Tests/app/assets/app.js @@ -7,11 +7,14 @@ * file that was distributed with this source code. */ -import { Application } from "@hotwired/stimulus"; +import { Application, Controller as BaseController } from "@hotwired/stimulus"; import Controller from "@symfony/ux-collection/dist/controller.js"; const application = Application.start(); application.register("symfony--ux-collection--collection", Controller); -application.register("collection", Controller); - +application.register("test", class test extends BaseController { + connect() { + console.log('Yolo', this.element); + } +}); console.log('test app initialized'); diff --git a/src/Collection/Tests/app/templates/basic_form.html.twig b/src/Collection/Tests/app/templates/basic_form.html.twig index 81ebd4d1577..138027ac5bd 100644 --- a/src/Collection/Tests/app/templates/basic_form.html.twig +++ b/src/Collection/Tests/app/templates/basic_form.html.twig @@ -1,7 +1,9 @@ {% extends 'base.html.twig' %} {% block body %} -
- {{ form(form) }} -
+ {{ form_start(form, {'attr': { + 'data-controller': 'symfony--ux-collection--collection' + }}) }} + {{ form(form) }} + {{ form_end(form) }} {% endblock %} diff --git a/src/Collection/Tests/app/templates/template_form.html.twig b/src/Collection/Tests/app/templates/template_form.html.twig index 77176461836..169ca50b3d2 100644 --- a/src/Collection/Tests/app/templates/template_form.html.twig +++ b/src/Collection/Tests/app/templates/template_form.html.twig @@ -1,15 +1,19 @@ {% extends 'base.html.twig' %} {% block body %} - + - + -
- {{ form(form) }} -
+ {{ form_start(form, {'attr': { + 'data-controller': 'symfony--ux-collection--collection', + 'data-symfony--ux-collection--collection-add-button-template-id-value': 'addButton', + 'data-symfony--ux-collection--collection-delete-button-template-id-value': 'deleteButton', + }}) }} + {{ form(form) }} + {{ form_end(form) }} {% endblock %} diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index d75ccabbe7a..f9c384f7851 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1,31 +1,31 @@ import { Controller } from '@hotwired/stimulus'; -/****************************************************************************** -Copyright (c) Microsoft Corporation. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. -***************************************************************************** */ - -function __classPrivateFieldGet(receiver, state, kind, f) { - if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); - if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); - return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); -} - -function __classPrivateFieldSet(receiver, state, value, kind, f) { - if (kind === "m") throw new TypeError("Private method is not writable"); - if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); - if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); - return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; +/****************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ + +function __classPrivateFieldGet(receiver, state, kind, f) { + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); + return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); +} + +function __classPrivateFieldSet(receiver, state, value, kind, f) { + if (kind === "m") throw new TypeError("Private method is not writable"); + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); + return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; } var DOCUMENT_FRAGMENT_NODE = 11; @@ -1084,8 +1084,8 @@ function getModelDirectiveFromInput(element, throwOnMissing = true) { } if (element.getAttribute('name')) { const formElement = element.closest('form'); - if (formElement && formElement.dataset.model) { - const directives = parseDirectives(formElement.dataset.model); + if (formElement && ('model' in formElement.dataset)) { + const directives = parseDirectives(formElement.dataset.model || '*'); const directive = directives[0]; if (directive.args.length > 0 || directive.named.length > 0) { throw new Error(`The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.`); @@ -1222,9 +1222,7 @@ class default_1 extends Controller { if (!(this.element instanceof HTMLElement)) { throw new Error('Invalid Element Type'); } - if (this.element.dataset.poll !== undefined) { - this._initiatePolling(this.element.dataset.poll); - } + this._initiatePolling(); window.addEventListener('beforeunload', this.markAsWindowUnloaded); this._startAttributesMutationObserver(); this.element.addEventListener('live:update-model', this.handleUpdateModelEvent); @@ -1234,9 +1232,7 @@ class default_1 extends Controller { this._dispatchEvent('live:connect', { controller: this }); } disconnect() { - this.pollingIntervals.forEach((interval) => { - clearInterval(interval); - }); + this._stopAllPolling(); window.removeEventListener('beforeunload', this.markAsWindowUnloaded); this.element.removeEventListener('live:update-model', this.handleUpdateModelEvent); this.element.removeEventListener('input', this.handleInputEvent); @@ -1665,7 +1661,12 @@ class default_1 extends Controller { } this._updateModelFromElement(target, 'change'); } - _initiatePolling(rawPollConfig) { + _initiatePolling() { + this._stopAllPolling(); + if (this.element.dataset.poll === undefined) { + return; + } + const rawPollConfig = this.element.dataset.poll; const directives = parseDirectives(rawPollConfig || '$render'); directives.forEach((directive) => { let duration = 2000; @@ -1790,9 +1791,12 @@ class default_1 extends Controller { const element = this.element; this.mutationObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { - if (mutation.type === 'attributes' && !element.dataset.originalData) { - this.originalDataJSON = this.valueStore.asJson(); - this._exposeOriginalData(); + if (mutation.type === 'attributes') { + if (!element.dataset.originalData) { + this.originalDataJSON = this.valueStore.asJson(); + this._exposeOriginalData(); + } + this._initiatePolling(); } }); }); @@ -1803,6 +1807,11 @@ class default_1 extends Controller { getDefaultDebounce() { return this.hasDebounceValue ? this.debounceValue : DEFAULT_DEBOUNCE; } + _stopAllPolling() { + this.pollingIntervals.forEach((interval) => { + clearInterval(interval); + }); + } } default_1.values = { url: String, From e141e33727189626a3cc63ca90244e6ec7bb593f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Wed, 20 Jul 2022 19:58:15 +0200 Subject: [PATCH 5/5] simplify button customization --- .../Resources/assets/dist/controller.js | 41 +++++++------- .../Resources/assets/src/controller.ts | 53 ++++++++++--------- .../app/templates/template_form.html.twig | 26 +++++++-- 3 files changed, 74 insertions(+), 46 deletions(-) diff --git a/src/Collection/Resources/assets/dist/controller.js b/src/Collection/Resources/assets/dist/controller.js index 04337f7ba0f..eecb6252531 100644 --- a/src/Collection/Resources/assets/dist/controller.js +++ b/src/Collection/Resources/assets/dist/controller.js @@ -24,21 +24,24 @@ class default_1 extends Controller { } 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 attributeName = `${ButtonType[buttonType].toLowerCase()}Button`; + const button = (_a = collectionEl.dataset[attributeName]) !== null && _a !== void 0 ? _a : this.element.dataset[attributeName]; + console.log(button); + if ('' === button) + return null; + if (undefined === button || !('content' in document.createElement('template'))) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = ButtonType[buttonType]; + return button; } - const button = document.createElement('button'); - button.type = 'button'; - button.textContent = buttonType === ButtonType.Add ? 'Add' : 'Delete'; - return button; + const buttonTemplate = document.getElementById(button); + if (!buttonTemplate) + throw new Error(`template with ID "${buttonTemplate}" 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; } addItem(collectionEl) { const currentIndex = collectionEl.dataset.currentIndex; @@ -57,6 +60,8 @@ class default_1 extends Controller { } addAddButton(collectionEl) { const addButton = this.createButton(collectionEl, ButtonType.Add); + if (!addButton) + return; addButton.onclick = (e) => { e.preventDefault(); this.addItem(collectionEl); @@ -65,6 +70,8 @@ class default_1 extends Controller { } addDeleteButton(collectionEl, itemEl) { const deleteButton = this.createButton(collectionEl, ButtonType.Delete); + if (!deleteButton) + return; deleteButton.onclick = (e) => { e.preventDefault(); itemEl.remove(); @@ -73,10 +80,8 @@ class default_1 extends Controller { } } default_1.values = { - addButtonTemplateId: "", - disableAddButton: false, - deleteButtonTemplateId: "", - disableDeleteButton: false, + addButton: '', + deleteButton: '', }; export { default_1 as default }; diff --git a/src/Collection/Resources/assets/src/controller.ts b/src/Collection/Resources/assets/src/controller.ts index ff2bac60f5c..b5c3ea8d981 100644 --- a/src/Collection/Resources/assets/src/controller.ts +++ b/src/Collection/Resources/assets/src/controller.ts @@ -6,10 +6,8 @@ interface CollectionDataset extends DOMStringMap { prototype: string; currentIndex: string; itemsSelector?: string; - addButtonTemplateId?: string; - disableAddButton?: string; - deleteButtonTemplateId?: string; - disableDeleteButton?: string; + addButton?: string; + deleteButton?: string; } enum ButtonType { @@ -19,10 +17,8 @@ enum ButtonType { export default class extends Controller { static values = { - addButtonTemplateId: '', - disableAddButton: false, - deleteButtonTemplateId: '', - disableDeleteButton: false, + addButton: '', + deleteButton: '', }; connect() { @@ -45,27 +41,32 @@ export default class extends Controller { return collectionElement.querySelectorAll(collectionElement.dataset.itemsSelector || DEFAULT_ITEMS_SELECTOR); } - 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`); + createButton(collectionEl: HTMLElement, buttonType: ButtonType): HTMLElement | null { + const attributeName = `${ButtonType[buttonType].toLowerCase()}Button`; + const button = collectionEl.dataset[attributeName] ?? (this.element as HTMLElement).dataset[attributeName]; + console.log(button); - 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'); + // Button explicitly disabled through data attribute + if ('' === button) return null; - return fragment.firstElementChild as HTMLElement; + // No data attribute provided or