├── .php-cs-fixer.php ├── LICENSE ├── README.md ├── composer.json └── src ├── CollectionJsBundle.php ├── DependencyInjection └── CollectionJsExtension.php ├── Form └── CollectionJsType.php ├── Resources ├── assets │ ├── .babelrc │ ├── dist │ │ └── controller.js │ ├── package.json │ └── src │ │ ├── controller.js │ │ └── style.css ├── config │ └── services.php ├── translations │ └── messages.en.php └── views │ ├── bootstrap_3_layout.html.twig │ ├── bootstrap_4_layout.html.twig │ ├── bootstrap_5_layout.html.twig │ ├── bootstrap_base_layout.html.twig │ └── form_div_layout.html.twig └── Twig └── CollectionJsTwigExtension.php /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | exclude('Resources/assets/dist') 5 | ->exclude('Resources/assets/node_modules') 6 | ->in(__DIR__.'/src') 7 | ->in(__DIR__.'/tests') 8 | ; 9 | 10 | $config = new PhpCsFixer\Config(); 11 | 12 | return $config 13 | ->setRules([ 14 | '@PSR12' => true, 15 | '@Symfony' => true, 16 | 'concat_space' => ['spacing' => 'one'], 17 | 'single_line_throw' => false, 18 | ]) 19 | ->setUsingCache(false) 20 | ->setFinder($finder) 21 | ; 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tien Vo Xuan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UX Collection JS [![Build Status][actions_badge]][actions_link] [![Coverage Status][coveralls_badge]][coveralls_link] 2 | 3 | UX collection JS is a Symfony bundle providing Symfony UX integration for collection form type with the help from [Symfony Collection JS](https://github.com/ruano-a/symfonyCollectionJs) library. 4 | 5 | ## Screenshots 6 | 7 | ### Bootstrap 3 8 |  9 | ### Bootstrap 5 10 |  11 | ### EasyAdmin 12 |  13 | 14 | ## Installation 15 | 16 | UX Collection JS requires PHP 7.4+ and Symfony 4.4+. 17 | 18 | Install this bundle using Composer and Symfony Flex: 19 | 20 | ```sh 21 | composer require tienvx/ux-collection-js:^1.0 22 | 23 | # Don't forget to install the JavaScript dependencies as well and compile 24 | yarn add --dev '@symfony/stimulus-bridge@^2.0.0' 25 | yarn install --force 26 | yarn encore dev 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### Symfony 32 | 33 | Use the new CollectionType class defined by this bundle: 34 | 35 | ```php 36 | // ... 37 | use Tienvx\UX\CollectionJs\Form\CollectionJsType; 38 | 39 | class PostType extends AbstractType 40 | { 41 | public function buildForm(FormBuilderInterface $builder, array $options) 42 | { 43 | $builder 44 | // ... 45 | ->add('authors', CollectionJsType::class, [ 46 | 'entry_type' => TextType::class, 47 | 'prototype' => true, 48 | 'allow_add' => true, 49 | 'allow_delete' => true, 50 | 'allow_move_up' => true, 51 | 'allow_move_down' => true, 52 | 'call_post_add_on_init' => true, 53 | ]) 54 | // ... 55 | ; 56 | } 57 | 58 | // ... 59 | } 60 | ``` 61 | 62 | Then you need to set the form theme: 63 | ```yaml 64 | # config/packages/twig.yaml 65 | twig: 66 | form_themes: 67 | - '@CollectionJs/bootstrap_5_layout.html.twig' 68 | ``` 69 | 70 | Available themes: 71 | - @CollectionJs/bootstrap_5_layout.html.twig 72 | - @CollectionJs/bootstrap_4_layout.html.twig 73 | - @CollectionJs/bootstrap_3_layout.html.twig 74 | 75 | ### Easyadmin 76 | 77 | Create webpack entry: 78 | 79 | ```javascript 80 | // webpack.config.js 81 | .addEntry('stimulus', './assets/stimulus.js') 82 | ``` 83 | 84 | Then create that javascript file: 85 | 86 | ```javascript 87 | // assets/stimulus.js 88 | 89 | // start the Stimulus application 90 | import './bootstrap'; 91 | ``` 92 | 93 | Use the new collection type in the easyadmin controller: 94 | 95 | ```php 96 | namespace App\Controller\EasyAdmin; 97 | 98 | use Tienvx\UX\CollectionJs\Form\CollectionJsType; 99 | 100 | class FormFieldReferenceController extends AbstractCrudController 101 | { 102 | public function configureCrud(Crud $crud): Crud 103 | { 104 | return $crud 105 | // ... 106 | ->setFormThemes(['@EasyAdmin/crud/form_theme.html.twig', '@CollectionJs/bootstrap_5_layout.html.twig']); 107 | } 108 | 109 | public function configureFields(string $pageName): iterable 110 | { 111 | yield CollectionField::new('collectionSimple', 'Collection Field (simple)') 112 | ->setFormType(CollectionJsType::class) 113 | ->setFormTypeOptions([ 114 | 'entry_type' => CollectionSimpleType::class, 115 | 'allow_add' => true, 116 | 'allow_delete' => true, 117 | 'allow_move_up' => true, 118 | 'allow_move_down' => true, 119 | 'call_post_add_on_init' => true, 120 | ]) 121 | ->addWebpackEncoreEntries('stimulus'); 122 | } 123 | } 124 | ``` 125 | 126 | ## Configuration 127 | 128 | | Config name | Description | Type | Default | 129 | |------------------------|---------------------------------------------------|----------|---------| 130 | | prototype | CollectionJsType form type need prototype = true | Boolean | true | 131 | | allow_add | Allow show/hide 'Add a new item' button | Boolean | false | 132 | | allow_delete | Allow show/hide 'Remove the item' button | Boolean | false | 133 | | allow_move_up | Allow show/hide 'Move item up' button | Boolean | false | 134 | | allow_move_down | Allow show/hide 'Move item down' button | Boolean | false | 135 | | call_post_add_on_init | Trigger 'ux-collection-js:post-add' event on init | Boolean | false | 136 | 137 | ## Stimulus Events 138 | 139 | | Namespace | Event | Description | Detail | 140 | |--------------------|-------------|-----------------------------|------------------------------| 141 | | ux-collection-js | post-add | After an item is added | new_elem, context, index | 142 | | ux-collection-js | post-delete | After an item is removed | delete_elem, context, index | 143 | | ux-collection-js | post-up | After an item is moved up | elem, switched_elem, index | 144 | | ux-collection-js | post-down | After an item is moved down | elem, switched_elem, index | 145 | 146 | ### Example 147 | 148 | ```php 149 | // SomeController.php 150 | $form = $this->createFormBuilder($task) 151 | ->add('some_field', SomeType::class, [ 152 | 'attr' => [ 153 | 'data-controller' => 'items', 154 | 'data-action' => 'ux-collection-js:post-add->items#postAdd ux-collection-js:post-delete->items#postDelete ', 155 | ], 156 | ]) 157 | ->getForm(); 158 | ``` 159 | 160 | ```js 161 | // items_controller.js 162 | import { Controller } from 'stimulus'; 163 | 164 | export default class extends Controller { 165 | postDelete(event) { 166 | const { delete_elem, context, index } = event.detail; 167 | } 168 | 169 | postAdd(event) { 170 | const { new_elem, context, index } = event.detail; 171 | } 172 | } 173 | ``` 174 | 175 | ## Contributing 176 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 177 | 178 | Please make sure to update tests as appropriate. 179 | 180 | ## License 181 | [MIT](LICENSE) 182 | 183 | [actions_badge]: https://github.com/tienvx/ux-collection-js/workflows/main/badge.svg 184 | [actions_link]: https://github.com/tienvx/ux-collection-js/actions 185 | 186 | [coveralls_badge]: https://coveralls.io/repos/tienvx/ux-collection-js/badge.svg?branch=main&service=github 187 | [coveralls_link]: https://coveralls.io/github/tienvx/ux-collection-js?branch=main 188 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tienvx/ux-collection-js", 3 | "type": "symfony-bundle", 4 | "description": "Symfony UX integration for collection form type", 5 | "keywords": [ 6 | "symfony-ux", 7 | "ux-collection-js" 8 | ], 9 | "homepage": "https://github.com/tienvx/ux-collection-js", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Tien", 14 | "email": "tien.xuan.vo@gmail.com" 15 | }, 16 | { 17 | "name": "Community contributions", 18 | "homepage": "https://github.com/tienvx/ux-collection-js/contributors" 19 | } 20 | ], 21 | "autoload": { 22 | "psr-4": { 23 | "Tienvx\\UX\\CollectionJs\\": "src" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "Tienvx\\UX\\CollectionJs\\Tests\\": "tests/" 29 | } 30 | }, 31 | "require": { 32 | "php": "^7.4|^8.0", 33 | "symfony/config": "^4.4|^5.0|^6.0", 34 | "symfony/dependency-injection": "^4.4.17|^5.0|^6.0", 35 | "symfony/form": "^4.4.17|^5.0|^6.0", 36 | "symfony/http-kernel": "^4.4.17|^5.0|^6.0", 37 | "twig/twig": "^2.12|^3.0" 38 | }, 39 | "require-dev": { 40 | "phpunit/phpunit": "^9.5", 41 | "symfony/framework-bundle": "^4.4.17|^5.0|^6.0", 42 | "symfony/twig-bundle": "^4.4.17|^5.0|^6.0" 43 | }, 44 | "conflict": { 45 | "symfony/flex": "<1.13" 46 | }, 47 | "extra": { 48 | "branch-alias": { 49 | "dev-main": "1.0-dev" 50 | }, 51 | "thanks": { 52 | "name": "ruano-a/symfonyCollectionJs", 53 | "url": "https://github.com/ruano-a/symfonyCollectionJs" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/CollectionJsBundle.php: -------------------------------------------------------------------------------- 1 | getParameter('kernel.bundles'); 17 | 18 | if (!isset($bundles['TwigBundle'])) { 19 | return; 20 | } 21 | 22 | $container->prependExtensionConfig('twig', ['form_themes' => ['@CollectionJs/form_div_layout.html.twig']]); 23 | } 24 | 25 | public function load(array $configs, ContainerBuilder $container) 26 | { 27 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); 28 | $loader->load('services.php'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Form/CollectionJsType.php: -------------------------------------------------------------------------------- 1 | $options['required'], 35 | 'label' => $options['prototype_name'] . 'label__', 36 | ], $options['entry_options']); 37 | 38 | if (null !== $options['prototype_data']) { 39 | $prototypeOptions['data'] = $options['prototype_data']; 40 | } 41 | 42 | $prototype = $builder->create($options['prototype_name'], $options['entry_type'], $prototypeOptions); 43 | $builder->setAttribute('prototype', $prototype->getForm()); 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function configureOptions(OptionsResolver $resolver): void 50 | { 51 | $entryOptionsNormalizer = function (Options $options, $value) { 52 | $value['block_prefix'] = $value['block_prefix'] ?? 'collection_js_entry'; 53 | 54 | return $value; 55 | }; 56 | 57 | $resolver->setDefaults([ 58 | 'allow_move_up' => false, 59 | 'allow_move_down' => false, 60 | 'call_post_add_on_init' => false, 61 | ]); 62 | $resolver->setNormalizer('entry_options', $entryOptionsNormalizer); 63 | } 64 | 65 | /** 66 | * {@inheritdoc} 67 | */ 68 | public function buildView(FormView $view, FormInterface $form, array $options): void 69 | { 70 | $view->vars = array_replace($view->vars, [ 71 | 'allow_move_up' => $options['allow_move_up'], 72 | 'allow_move_down' => $options['allow_move_down'], 73 | 'prototype_name' => $options['prototype_name'], 74 | 'call_post_add_on_init' => $options['call_post_add_on_init'], 75 | 'collection_id' => uniqid(), 76 | ]); 77 | } 78 | 79 | /** 80 | * {@inheritdoc} 81 | */ 82 | public function getBlockPrefix(): string 83 | { 84 | return 'collection_js'; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Resources/assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"], 3 | "plugins": ["@babel/plugin-proposal-class-properties"] 4 | } 5 | -------------------------------------------------------------------------------- /src/Resources/assets/dist/controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports["default"] = void 0; 9 | 10 | var _stimulus = require("@hotwired/stimulus"); 11 | 12 | var _symfonyCollectionJs = _interopRequireDefault(require("symfony-collection-js")); 13 | 14 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; } 15 | 16 | function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) { symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); } keys.push.apply(keys, symbols); } return keys; } 17 | 18 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } 19 | 20 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 21 | 22 | function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } 23 | 24 | function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } 25 | 26 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); } 27 | 28 | function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } 29 | 30 | function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; } 31 | 32 | function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); } 33 | 34 | function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } 35 | 36 | function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } } 37 | 38 | function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); } 39 | 40 | function _classPrivateMethodInitSpec(obj, privateSet) { _checkPrivateRedeclaration(obj, privateSet); privateSet.add(obj); } 41 | 42 | function _checkPrivateRedeclaration(obj, privateCollection) { if (privateCollection.has(obj)) { throw new TypeError("Cannot initialize the same private elements twice on an object"); } } 43 | 44 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 45 | 46 | function _classPrivateMethodGet(receiver, privateSet, fn) { if (!privateSet.has(receiver)) { throw new TypeError("attempted to get private field on non-instance"); } return fn; } 47 | 48 | var _dispatchCollectionJsEvent = /*#__PURE__*/new WeakSet(); 49 | 50 | var _default = /*#__PURE__*/function (_Controller) { 51 | _inherits(_default, _Controller); 52 | 53 | var _super = _createSuper(_default); 54 | 55 | function _default() { 56 | var _this; 57 | 58 | _classCallCheck(this, _default); 59 | 60 | for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { 61 | args[_key] = arguments[_key]; 62 | } 63 | 64 | _this = _super.call.apply(_super, [this].concat(args)); 65 | 66 | _classPrivateMethodInitSpec(_assertThisInitialized(_this), _dispatchCollectionJsEvent); 67 | 68 | return _this; 69 | } 70 | 71 | _createClass(_default, [{ 72 | key: "connect", 73 | value: function connect() { 74 | var _self = this; 75 | 76 | var options = { 77 | call_post_add_on_init: this.callPostAddOnInitValue, 78 | post_add: function post_add(new_elem, context, index) { 79 | _classPrivateMethodGet(_self, _dispatchCollectionJsEvent, _dispatchCollectionJsEvent2).call(_self, 'post-add', { 80 | new_elem: new_elem, 81 | context: context, 82 | index: index 83 | }); 84 | }, 85 | post_delete: function post_delete(delete_elem, context, index) { 86 | _classPrivateMethodGet(_self, _dispatchCollectionJsEvent, _dispatchCollectionJsEvent2).call(_self, 'post-delete', { 87 | delete_elem: delete_elem, 88 | context: context, 89 | index: index 90 | }); 91 | }, 92 | post_up: function post_up(elem, switched_elem, index) { 93 | _classPrivateMethodGet(_self, _dispatchCollectionJsEvent, _dispatchCollectionJsEvent2).call(_self, 'post-up', { 94 | elem: elem, 95 | switched_elem: switched_elem, 96 | index: index 97 | }); 98 | }, 99 | post_down: function post_down(elem, switched_elem, index) { 100 | _classPrivateMethodGet(_self, _dispatchCollectionJsEvent, _dispatchCollectionJsEvent2).call(_self, 'post-down', { 101 | elem: elem, 102 | switched_elem: switched_elem, 103 | index: index 104 | }); 105 | }, 106 | prototype_name: this.prototypeNameValue || '__name__' 107 | }; 108 | 109 | if (this.allowAddValue) { 110 | options = _objectSpread(_objectSpread({}, options), {}, { 111 | other_btn_add: this.element.querySelector(".collection-js-".concat(this.collectionIdValue, "-add-btn")), 112 | btn_add_selector: ".collection-js-".concat(this.collectionIdValue, "-elem-add") 113 | }); 114 | } 115 | 116 | if (this.allowDeleteValue) { 117 | options.btn_delete_selector = ".collection-js-".concat(this.collectionIdValue, "-elem-remove"); 118 | } 119 | 120 | if (this.allowMoveUpValue) { 121 | options.btn_up_selector = ".collection-js-".concat(this.collectionIdValue, "-elem-up"); 122 | } 123 | 124 | if (this.allowMoveDownValue) { 125 | options.btn_down_selector = ".collection-js-".concat(this.collectionIdValue, "-elem-down"); 126 | } 127 | 128 | (0, _symfonyCollectionJs["default"])(this.element.querySelector('.collection-js-root'), options); 129 | } 130 | }]); 131 | 132 | return _default; 133 | }(_stimulus.Controller); 134 | 135 | exports["default"] = _default; 136 | 137 | function _dispatchCollectionJsEvent2(event, detail) { 138 | var namespace = 'ux-collection-js'; 139 | this.element.dispatchEvent(new CustomEvent("".concat(namespace, ":").concat(event), { 140 | bubbles: true, 141 | cancelable: true, 142 | detail: detail 143 | })); 144 | } 145 | 146 | _defineProperty(_default, "values", { 147 | allowAdd: Boolean, 148 | allowDelete: Boolean, 149 | allowMoveUp: Boolean, 150 | allowMoveDown: Boolean, 151 | prototypeName: String, 152 | callPostAddOnInit: Boolean, 153 | collectionId: String 154 | }); -------------------------------------------------------------------------------- /src/Resources/assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tienvx/ux-collection-js", 3 | "description": "Symfony UX integration for collection form type", 4 | "license": "MIT", 5 | "version": "1.1.0", 6 | "symfony": { 7 | "controllers": { 8 | "collection": { 9 | "main": "dist/controller.js", 10 | "webpackMode": "eager", 11 | "fetch": "eager", 12 | "enabled": true, 13 | "autoimport": { 14 | "@tienvx/ux-collection-js/src/style.css": true 15 | } 16 | } 17 | } 18 | }, 19 | "scripts": { 20 | "build": "babel src -d dist", 21 | "test": "babel src -d dist && jest", 22 | "lint": "eslint src test --fix", 23 | "format": "prettier {src,test}/*.js --write", 24 | "check-lint": "yarn lint --no-fix", 25 | "check-format": "yarn format --no-write --check" 26 | }, 27 | "dependencies": { 28 | "symfony-collection-js": "4.2.0-js-only" 29 | }, 30 | "peerDependencies": { 31 | "@hotwired/stimulus": "^3.0.0" 32 | }, 33 | "devDependencies": { 34 | "@babel/cli": "^7.12.1", 35 | "@babel/core": "^7.12.3", 36 | "@babel/plugin-proposal-class-properties": "^7.12.1", 37 | "@babel/preset-env": "^7.12.7", 38 | "@symfony/stimulus-testing": "^2.0.1", 39 | "@hotwired/stimulus": "^3.0.0", 40 | "@babel/eslint-parser": "^7.12.1", 41 | "eslint": "^7.15.0", 42 | "eslint-config-prettier": "^6.15.0", 43 | "eslint-plugin-jest": "^24.1.3", 44 | "prettier": "^2.2.1" 45 | }, 46 | "jest": { 47 | "testRegex": "test/.*\\.test.js", 48 | "setupFilesAfterEnv": [ 49 | "./test/setup.js" 50 | ] 51 | }, 52 | "eslintConfig": { 53 | "root": true, 54 | "parser": "@babel/eslint-parser", 55 | "extends": [ 56 | "eslint:recommended", 57 | "prettier" 58 | ], 59 | "env": { 60 | "browser": true 61 | }, 62 | "overrides": [ 63 | { 64 | "files": [ 65 | "test/*.js" 66 | ], 67 | "extends": [ 68 | "plugin:jest/recommended" 69 | ] 70 | } 71 | ] 72 | }, 73 | "prettier": { 74 | "printWidth": 120, 75 | "trailingComma": "es5", 76 | "tabWidth": 4, 77 | "jsxBracketSameLine": true, 78 | "singleQuote": true 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Resources/assets/src/controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Controller } from '@hotwired/stimulus'; 4 | import formCollection from 'symfony-collection-js'; 5 | 6 | export default class extends Controller { 7 | static values = { 8 | allowAdd: Boolean, 9 | allowDelete: Boolean, 10 | allowMoveUp: Boolean, 11 | allowMoveDown: Boolean, 12 | prototypeName: String, 13 | callPostAddOnInit: Boolean, 14 | collectionId: String, 15 | }; 16 | 17 | connect() { 18 | const _self = this; 19 | let options = { 20 | call_post_add_on_init: this.callPostAddOnInitValue, 21 | post_add: function (new_elem, context, index) { 22 | _self.#dispatchCollectionJsEvent('post-add', { new_elem, context, index }); 23 | }, 24 | post_delete: function (delete_elem, context, index) { 25 | _self.#dispatchCollectionJsEvent('post-delete', { delete_elem, context, index }); 26 | }, 27 | post_up: function (elem, switched_elem, index) { 28 | _self.#dispatchCollectionJsEvent('post-up', { elem, switched_elem, index }); 29 | }, 30 | post_down: function (elem, switched_elem, index) { 31 | _self.#dispatchCollectionJsEvent('post-down', { elem, switched_elem, index }); 32 | }, 33 | prototype_name: this.prototypeNameValue || '__name__', 34 | }; 35 | if (this.allowAddValue) { 36 | options = { 37 | ...options, 38 | other_btn_add: this.element.querySelector(`.collection-js-${this.collectionIdValue}-add-btn`), 39 | btn_add_selector: `.collection-js-${this.collectionIdValue}-elem-add`, 40 | }; 41 | } 42 | if (this.allowDeleteValue) { 43 | options.btn_delete_selector = `.collection-js-${this.collectionIdValue}-elem-remove`; 44 | } 45 | if (this.allowMoveUpValue) { 46 | options.btn_up_selector = `.collection-js-${this.collectionIdValue}-elem-up`; 47 | } 48 | if (this.allowMoveDownValue) { 49 | options.btn_down_selector = `.collection-js-${this.collectionIdValue}-elem-down`; 50 | } 51 | 52 | formCollection(this.element.querySelector('.collection-js-root'), options); 53 | } 54 | 55 | #dispatchCollectionJsEvent(event, detail) { 56 | const namespace = 'ux-collection-js'; 57 | this.element.dispatchEvent( 58 | new CustomEvent(`${namespace}:${event}`, { 59 | bubbles: true, 60 | cancelable: true, 61 | detail, 62 | }) 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Resources/assets/src/style.css: -------------------------------------------------------------------------------- 1 | /* Common */ 2 | .collection-js-accordion-collapse-marker { 3 | margin: 0 8px 0 4px; 4 | } 5 | 6 | .form-group.collection-js [data-toggle="collapse"]:not(.collapsed) i { 7 | transform: rotate(90deg); 8 | } 9 | 10 | /* Bootstrap 5 & Easy Admin */ 11 | .form-group.collection-js.field-collection .accordion-header { 12 | padding-right: 0; 13 | } 14 | 15 | .form-group.collection-js .accordion-header { 16 | display: flex; 17 | } 18 | 19 | .form-group.collection-js .accordion-button:after { 20 | /* hides the default collapse marker */ 21 | display: none; 22 | } 23 | 24 | .form-group.collection-js .accordion-button:not(.collapsed) i { 25 | transform: rotate(90deg); 26 | } 27 | 28 | .form-group.collection-js:not(.field-collection) .accordion-button:not(.collapsed) + .collection-js-actions { 29 | color: #283848; 30 | background-color: #eaecee; 31 | box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.125); 32 | } 33 | 34 | /* Bootstrap 3, 4 */ 35 | .form-group.collection-js .panel-heading, 36 | .form-group.collection-js .card-header { 37 | display: flex; 38 | } 39 | 40 | .form-group.collection-js .panel-heading > [data-toggle="collapse"], 41 | .form-group.collection-js .card-header > [data-toggle="collapse"] { 42 | width: 100%; 43 | text-align: left; 44 | align-items: center; 45 | display: flex; 46 | } 47 | 48 | .form-group.collection-js .panel-heading > a[data-toggle="collapse"], 49 | .form-group.collection-js .card-header > a[data-toggle="collapse"] { 50 | text-decoration: none; 51 | } 52 | 53 | .form-group.collection-js .panel-heading > .collection-js-actions, 54 | .form-group.collection-js .card-header > .collection-js-actions { 55 | display: inline-flex; 56 | } 57 | -------------------------------------------------------------------------------- /src/Resources/config/services.php: -------------------------------------------------------------------------------- 1 | services() 10 | ->set(CollectionJsType::class) 11 | ->tag('form.type') 12 | ->set(CollectionJsTwigExtension::class) 13 | ->tag('twig.extension') 14 | ; 15 | }; 16 | -------------------------------------------------------------------------------- /src/Resources/translations/messages.en.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'add_new_item' => 'Add a new item', 6 | 'move_item_up' => 'Move item up', 7 | 'move_item_down' => 'Move item down', 8 | 'remove_item' => 'Remove the item', 9 | ], 10 | ]; 11 | -------------------------------------------------------------------------------- /src/Resources/views/bootstrap_3_layout.html.twig: -------------------------------------------------------------------------------- 1 | {% use "@CollectionJs/bootstrap_base_layout.html.twig" %} 2 | 3 | {% block collection_js_accordion_class %}panel-group{% endblock collection_js_accordion_class %} 4 | 5 | {% block collection_js_accordion_item %} 6 |