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/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..1139f3bf508 --- /dev/null +++ b/src/Collection/Form/CollectionTypeExtension.php @@ -0,0 +1,119 @@ +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 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) { + 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, // TODO add AddButtonType for easier theming and extending + 'add_options' => [], + 'delete_type' => ButtonType::class, // TODO add DeleteButtonType for easier theming and extending + 'delete_options' => [], + ]); + + $addOptionsNormalizer = function (Options $options, $value) { + $value['attr'] = \array_merge([ + 'data-action' => $options['attr']['data-controller'] . '#add', + ], $value['attr'] ?? []); + + return $value; + }; + + $deleteOptionsNormalizer = function (Options $options, $value) { + $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/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..63117bcdbe9 --- /dev/null +++ b/src/Collection/Resources/assets/dist/controller.js @@ -0,0 +1,70 @@ +import { Controller } from '@hotwired/stimulus'; + +class default_1 extends Controller { + constructor() { + super(...arguments); + this.index = 0; + this.controllerName = 'collection'; + } + connect() { + this.controllerName = this.context.scope.identifier; + this._dispatchEvent('collection:connect'); + } + add() { + const prototypeHTML = this.element.dataset.prototype; + if (!prototypeHTML) { + throw new Error('A "data-prototype" attribute was expected on data-controller="' + this.controllerName + '" element.'); + } + const 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, + }); + 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); + } + else { + this.element.prepend(newEntry); + } + this._dispatchEvent('collection:add', { + entry: newEntry, + index: this.index, + }); + this.index++; + } + delete(event) { + const clickTarget = event.target; + const entry = clickTarget.closest('[data-' + this.controllerName + '-target="entry"]'); + this._dispatchEvent('collection:pre-delete', { + entry: entry, + }); + entry.remove(); + this._dispatchEvent('collection:delete', { + entry: entry, + }); + } + _textToNode(text) { + const template = document.createElement('template'); + text = text.trim(); + template.innerHTML = text; + return template.content.firstChild; + } + _dispatchEvent(name, payload = {}) { + this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); + } +} +default_1.values = { + prototypeName: String, + itemSelector: String, +}; + +export { default_1 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..6a579923768 --- /dev/null +++ b/src/Collection/Resources/assets/src/controller.ts @@ -0,0 +1,98 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static values = { + prototypeName: String, + itemSelector: String, + } + + // @ts-ignore + declare readonly element: HTMLElement; + declare readonly prototypeNameValue: string; + declare readonly itemSelectorValue: string; + + index = 0; + controllerName = 'collection'; + + connect() { + this.controllerName = this.context.scope.identifier; + this._dispatchEvent('collection:connect'); + } + + add() { + const prototypeHTML = this.element.dataset.prototype; + + if (!prototypeHTML) { + throw new Error( + 'A "data-prototype" attribute was expected on data-controller="' + this.controllerName + '" element.' + ); + } + + // 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, + }); + + 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); + } + + this._dispatchEvent('collection:add', { + entry: newEntry, + index: this.index, + }); + + this.index++; + } + + delete(event: MouseEvent) { + const clickTarget = event.target as HTMLButtonElement; + + const entry = clickTarget.closest('[data-' + this.controllerName + '-target="entry"]') as HTMLElement; + + this._dispatchEvent('collection:pre-delete', { + entry: entry, + }); + + entry.remove(); + + this._dispatchEvent('collection:delete', { + entry: entry, + }); + } + + _textToNode(text: string): HTMLElement { + const template = document.createElement('template'); + text = text.trim(); + + template.innerHTML = text; + + return template.content.firstChild as HTMLElement; + } + + _dispatchEvent(name: string, payload: {} = {}) { + this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); + } +} diff --git a/src/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..322ff17a804 --- /dev/null +++ b/src/Collection/Tests/app/Entity/Game.php @@ -0,0 +1,38 @@ + + */ +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); + } + + 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..4dabecfa727 --- /dev/null +++ b/src/Collection/Tests/app/Form/GameType.php @@ -0,0 +1,46 @@ + + */ +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_options' => [ + 'label' => 'Add Team', + ], + 'delete_options' => [ + 'label' => 'Remove Team', + ], + ]) + ->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..b0f05f247b9 --- /dev/null +++ b/src/Collection/Tests/app/Form/TeamType.php @@ -0,0 +1,42 @@ + + */ +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, + 'add_options' => [ + 'label' => 'Add Player', + ], + 'delete_options' => [ + 'label' => 'Remove Player', + ], + ]) + ; + } + + 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..eddff3c16e5 --- /dev/null +++ b/src/Collection/Tests/app/Kernel.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +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; +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', '/{type<^basic|template$>?basic}')->controller('kernel::form'); + } + + public function getProjectDir(): string + { + return __DIR__; + } + + public function form(Request $request, Environment $twig, FormFactoryInterface $formFactory, string $type): Response + { + $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 = 'A2'; + $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()) { + // Update array keys + $form = $formFactory->create(GameType::class, $form->getData()); + } + + return new Response( + $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 new file mode 100644 index 00000000000..8f05c84fb6b --- /dev/null +++ b/src/Collection/Tests/app/assets/app.js @@ -0,0 +1,17 @@ +/* + * 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); +application.register("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/basic_form.html.twig b/src/Collection/Tests/app/templates/basic_form.html.twig new file mode 100644 index 00000000000..002d63a5037 --- /dev/null +++ b/src/Collection/Tests/app/templates/basic_form.html.twig @@ -0,0 +1,8 @@ +{% extends 'base.html.twig' %} + +{% block body %} +
+ {% 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..913d44bfbe8 --- /dev/null +++ b/src/Collection/Tests/app/templates/example_theme.html.twig @@ -0,0 +1,77 @@ +{% 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_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: + 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() }} +{%- 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;' + }) %} + +
+ {{ parent() }} +
+{%- 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 + + + + + + + + + + +